diff --git a/README.md b/README.md index 767f6a98..a334592e 100644 --- a/README.md +++ b/README.md @@ -784,15 +784,22 @@ the suite, and by any module deciding which of its own resources a caller sees. The authority knows resources by id only; each module maps ids onto its own. On the local authority a role's scope may carry `resources`. A scope with -none covers the whole kind. A person's answer is what her roles grant. A -module's or an installation's own work is bounded by its capability alone. A -capability narrowed to named ids covers only the ones the owner's grants also -cover. A capability that does not hold the action is refused. +none covers the whole kind. A scope with resources is never issued as the +whole kind (2026-10-26): its ids are the capability's resource ids, so an ask +naming none is issued on exactly those ids, and an ask naming any other id, or +the whole kind, is refused, at the root and at every hop. A token cannot carry +a prefix or a pattern, so one holds only the ids an ask (or the capability a +hop derives from) names that it matches, and an ask naming none carries none +of it. A person's answer is what her roles grant, cut to the ids her +capability names. A module's or an installation's own work is bounded by its +capability alone. A capability that does not hold the action is refused. The suite line "the authority answers which resources a context covers, by grant" holds an implementation to a fixture's covered and uncovered ids, an -unheld action, and a narrowed capability. It fails on an implementation that -answers every resource. +unheld action, an empty ask that never carries the whole kind, and the refusal +of an ask naming an uncovered id or the whole kind. It fails on an +implementation that answers every resource or issues a resource-scoped role as +the whole kind. Building it exposed an attenuation bug: the local authority's scope operations dropped resource ids, so a capability asked for two named resources was issued for the diff --git a/libraries/authority-core/go/authority/attachment.go b/libraries/authority-core/go/authority/attachment.go index 5663e01f..291bea67 100644 --- a/libraries/authority-core/go/authority/attachment.go +++ b/libraries/authority-core/go/authority/attachment.go @@ -96,7 +96,8 @@ func (s *Service) AttachResource(ctx context.Context, r AttachmentRequest) (stri return err } owner = inst.ID - return nil + return s.journal(ctx, tx, domain.Event{Kind: domain.ResourceAttached, Organization: inst.Organization, Actor: r.Store.Module, Subject: inst.ID, + Detail: map[string]string{"kind": r.Kind, "resource": r.ResourceID}}) }) return owner, err } @@ -136,7 +137,8 @@ func (s *Service) DetachResource(ctx context.Context, caller Workload, org, kind return err } owner = inst.ID - return nil + return s.journal(ctx, tx, domain.Event{Kind: domain.ResourceDetached, Organization: org, Actor: caller.Module, Subject: inst.ID, + Detail: map[string]string{"kind": kind, "resource": id}}) } return nil }) diff --git a/libraries/authority-core/go/authority/check.go b/libraries/authority-core/go/authority/check.go index 087b1de1..69b72ff3 100644 --- a/libraries/authority-core/go/authority/check.go +++ b/libraries/authority-core/go/authority/check.go @@ -52,6 +52,36 @@ func (s *Service) Check(ctx context.Context, token, audience string) (*workconte return verified, revision, nil } +// CheckMethod is Check, then what the audience module's delivery declares +// for method applied to what the call's current actor holds (the last hop's +// grant, never the owner's authority above it): decide.AdmitMethod. An empty +// method is Check alone. Advisory decides the method but does not enforce it: +// a sound capability is answered with the method's refusal beside it, the +// same refusal a check that enforces it returns as its error. +func (s *Service) CheckMethod(ctx context.Context, token, audience, method string, advisory bool) (verified *workcontext.Verified, revision uint64, refusal error, err error) { + if audience == "" { + return nil, 0, nil, domain.Refuse("a check names the audience the caller is") + } + if verified, revision, err = s.Check(ctx, token, audience); err != nil || method == "" { + return verified, revision, nil, err + } + err = s.store.Read(ctx, func(tx Tx) error { + m, err := tx.Module(audience) + if err != nil { + return notFound(err) + } + refusal = decide.AdmitMethod(m, method, verified.EffectiveScopes()) + return nil + }) + if err != nil { + return nil, 0, nil, err + } + if refusal != nil && !advisory { + return nil, 0, nil, refusal + } + return verified, revision, refusal, nil +} + // Grant is one way a capability covers resources of a kind: a resource match // and, when set, the owner the resource must have. type Grant struct { @@ -81,7 +111,7 @@ func (s *Service) ListResources(ctx context.Context, token, audience, kind, acti err = s.store.Read(ctx, func(tx Tx) error { rows := effective if wc.GetOwnerPrincipalKind() == "human" { - p, err := tx.Person(wc.GetOwnerPrincipalId()) + p, err := tx.Member(wc.GetTenantId(), wc.GetOwnerPrincipalId()) if err != nil { return domain.Refuse("the owner %s is no longer a member", wc.GetOwnerPrincipalId()) } diff --git a/libraries/authority-core/go/authority/consent.go b/libraries/authority-core/go/authority/consent.go index 5c260599..17ba5877 100644 --- a/libraries/authority-core/go/authority/consent.go +++ b/libraries/authority-core/go/authority/consent.go @@ -13,16 +13,28 @@ import ( ) func administrator(tx Tx, caller Person, org string) error { - p, err := tx.Person(caller.ID) + p, err := memberAs(tx, caller, org) if err != nil && !errors.Is(err, domain.ErrNotFound) { return err } - if caller.ID == "" || err != nil || p.Organization != org || !decide.IsAdministrator(p) { + if err != nil || !decide.IsAdministrator(p) { return domain.Refuse("installation administration requires an administrator or owner of organization %q", org) } return nil } +// memberAs is the caller as a member of org, read now. The caller acts in the +// organization their session was opened at: a person who belongs to several +// organizations administers or reads another one only from a session there, +// so a session never carries one organization's authority into another. +// Anything else is ErrNotFound, as an absent member is. +func memberAs(tx Tx, caller Person, org string) (domain.Person, error) { + if caller.ID == "" || org == "" || caller.Organization != org { + return domain.Person{}, fmt.Errorf("%q acting at %q: %w", caller.ID, org, domain.ErrNotFound) + } + return tx.Member(org, caller.ID) +} + // offerDigest covers every authority declaration in the reviewed module. JSON // sorts map keys; the receipt therefore covers roles, ceilings and bindings too. func offerDigest(m domain.Module) (string, error) { diff --git a/libraries/authority-core/go/authority/current_test.go b/libraries/authority-core/go/authority/current_test.go index 1dcf7676..ee107a37 100644 --- a/libraries/authority-core/go/authority/current_test.go +++ b/libraries/authority-core/go/authority/current_test.go @@ -10,7 +10,8 @@ import ( func TestCurrentInstallationIsConfinedToTheAuthenticatedAudience(t *testing.T) { h := newHarness(t) - parent := h.must(h.root("rob", "store", sc(kind, "read"))) + // rob and omar read store-x alone: each asks for what they hold. + parent := h.must(h.root("rob", "store", owned(kind, "", []string{"read"}, "store-x"))) inst, err := h.svc.CurrentInstallation(h.ctx, parent.Token, authority.Workload{Module: "store"}) if err != nil || inst.ID != authority.StableInstallationID(acme, "store") || inst.Organization != acme || inst.Target != "store" { t.Fatalf("current installation: %+v %v", inst, err) @@ -25,7 +26,7 @@ func TestCurrentInstallationIsConfinedToTheAuthenticatedAudience(t *testing.T) { } h.store.PutPerson(domain.Person{ID: "omar", Organization: globex, Roles: []string{"store-reader"}}) h.store.PutOrgRole(domain.OrgRole{Organization: globex, Name: "store-reader", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"read"}, Resources: []resources.Grant{{ID: "store-x"}}}}}) - foreign := h.must(h.root("omar", "store", sc(kind, "read"))) + foreign := h.must(h.root("omar", "store", owned(kind, "", []string{"read"}, "store-x"))) other, err := h.svc.CurrentInstallation(h.ctx, foreign.Token, authority.Workload{Module: "store"}) if err != nil || other.Organization != globex || other.ID == inst.ID { t.Fatalf("tenant isolation: %+v %v", other, err) diff --git a/libraries/authority-core/go/authority/delegation.go b/libraries/authority-core/go/authority/delegation.go index e737cdfa..72888bde 100644 --- a/libraries/authority-core/go/authority/delegation.go +++ b/libraries/authority-core/go/authority/delegation.go @@ -2,6 +2,7 @@ package authority import ( "context" + "errors" "github.com/codefly-dev/authority/libraries/authority-core/go/decide" "github.com/codefly-dev/authority/libraries/authority-core/go/domain" @@ -15,7 +16,7 @@ import ( func (s *Service) Delegate(ctx context.Context, who Person, module string, scopes Scopes, purpose string) (domain.Delegation, error) { var d domain.Delegation err := s.store.Write(ctx, func(tx Tx) error { - p, err := tx.Person(who.ID) + p, err := tx.Member(who.Organization, who.ID) if err != nil { return notFound(err) } @@ -26,7 +27,15 @@ func (s *Service) Delegate(ctx context.Context, who Person, module string, scope if len(scopes) == 0 { return domain.Refuse("a delegation names what it grants") } - holdings, err := held(tx, p, m) + if len(purpose) > MaxDelegationPurpose { + return domain.Malformed("a delegation's purpose is at most %d characters, not %d", MaxDelegationPurpose, len(purpose)) + } + // A module not installed at the grantor's organization could never + // issue from the delegation: it is refused rather than recorded. + if inst, err := installationOf(tx, p.Organization, m.Name); err != nil || inst.Status != domain.Active { + return domain.NotNow("module %q is not installed in organization %s, so nothing could issue from a delegation to it", m.Name, p.Organization) + } + holdings, err := held(tx, p, m, scopes) if err != nil { return err } @@ -35,12 +44,12 @@ func (s *Service) Delegate(ctx context.Context, who Person, module string, scope if err != nil { continue } - there, _, _, err := throughSolutions(tx, p, target, []string{m.Principal}) + there, _, _, err := throughSolutions(tx, p, target, []string{m.Principal}, scopes) if err != nil { return err } if m.Kind != domain.KindSolution { - if there, err = held(tx, p, target); err != nil { + if there, err = held(tx, p, target, scopes); err != nil { return err } } @@ -51,13 +60,21 @@ func (s *Service) Delegate(ctx context.Context, who Person, module string, scope return domain.Refuse("%s delegates what they do not hold", p.ID) } d = domain.Delegation{ID: s.cfg.NewID(), Organization: p.Organization, Grantor: p.ID, Module: m.Name, Purpose: purpose, Scopes: scope.Normalize(scopes)} - return tx.PutDelegation(d) + if err := tx.PutDelegation(d); err != nil { + return err + } + return s.journal(ctx, tx, domain.Event{Kind: domain.DelegationGranted, Organization: d.Organization, Actor: d.Grantor, Subject: d.ID, + Detail: map[string]string{"module": d.Module, "purpose": d.Purpose}}) }) return d, err } -// RevokeDelegation ends a delegation; only its grantor may. Its capabilities -// are refused at once. +// MaxDelegationPurpose bounds the purpose a delegation records. +const MaxDelegationPurpose = 1024 + +// RevokeDelegation ends a delegation: its grantor may, and so may an +// administrator of its organization acting there. Anyone else learns nothing, +// not even that it exists. Its capabilities are refused at once. func (s *Service) RevokeDelegation(ctx context.Context, who Person, id string) (domain.Delegation, error) { var d domain.Delegation err := s.store.Write(ctx, func(tx Tx) error { @@ -66,7 +83,13 @@ func (s *Service) RevokeDelegation(ctx context.Context, who Person, id string) ( return notFound(err) } if d.Grantor != who.ID { - return domain.Refuse("only the grantor of %s may revoke it", id) + admin, err := memberAs(tx, who, d.Organization) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + return err + } + if err != nil || !decide.IsAdministrator(admin) { + return domain.Missing("delegation %q", id) + } } if d.Revoked { return nil @@ -75,7 +98,11 @@ func (s *Service) RevokeDelegation(ctx context.Context, who Person, id string) ( if err := tx.PutDelegation(d); err != nil { return err } - return tx.BumpAuthorizationRevision(d.Organization) + if err := tx.BumpAuthorizationRevision(d.Organization); err != nil { + return err + } + return s.journal(ctx, tx, domain.Event{Kind: domain.DelegationRevoked, Organization: d.Organization, Actor: who.ID, Subject: d.ID, + Detail: map[string]string{"module": d.Module}}) }) return d, err } @@ -138,11 +165,11 @@ func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workl if through, err = decide.ScopesFor(binding, r.Operation, "invoke"); err != nil { return err } - p, err := tx.Person(d.Grantor) + p, err := tx.Member(d.Organization, d.Grantor) if err != nil { return domain.Refuse("the grantor %s is no longer a member", d.Grantor) } - h, _, _, err := throughSolutions(tx, p, audience, []string{am.Principal}) + h, _, _, err := throughSolutions(tx, p, audience, []string{am.Principal}, d.Scopes) if err != nil { return err } diff --git a/libraries/authority-core/go/authority/delegation_rules_test.go b/libraries/authority-core/go/authority/delegation_rules_test.go new file mode 100644 index 00000000..7ec67454 --- /dev/null +++ b/libraries/authority-core/go/authority/delegation_rules_test.go @@ -0,0 +1,68 @@ +package authority_test + +import ( + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// A delegation is to a module installed at the grantor's organization, with a +// bounded purpose; its grantor or an administrator of its organization ends +// it, and anyone else learns nothing of it. +func TestDelegationRules(t *testing.T) { + h := newHarness(t) + read := []*basev0.WorkScopeV1{sc(kind, "read")} + rob := h.person("rob") + + t.Run("a purpose past its bound is malformed", func(t *testing.T) { + _, err := h.svc.Delegate(h.ctx, rob, "store", read, strings.Repeat("p", authority.MaxDelegationPurpose+1)) + code(t, err, domain.Invalid) + }) + t.Run("a module not installed at the grantor's organization is refused", func(t *testing.T) { + if err := h.svc.UninstallAsHost(h.ctx, acme, authority.StableInstallationID(acme, "store")); err != nil { + t.Fatal(err) + } + _, err := h.svc.Delegate(h.ctx, rob, "store", read, "a job") + code(t, err, domain.Precondition) + refused(t, err, "is not installed") + }) +} + +func TestADelegationIsEndedByItsGrantorOrAnAdministratorAlone(t *testing.T) { + h := newHarness(t) + // Rob reads store-x alone: he delegates that, never the whole kind. + read := []*basev0.WorkScopeV1{owned(kind, "", []string{"read"}, "store-x")} + rob, maya, alex, zoe := h.person("rob"), h.person("maya"), h.person("alex"), h.person("zoe") + for _, tc := range []struct { + name string + who authority.Person + ok bool + reason domain.Code + }{ + {"another member", maya, false, domain.NotFound}, + {"another organization's administrator", zoe, false, domain.NotFound}, + {"an administrator of its organization", alex, true, 0}, + {"its grantor", rob, true, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + d, err := h.svc.Delegate(h.ctx, rob, "store", read, "a job") + if err != nil { + t.Fatal(err) + } + ended, err := h.svc.RevokeDelegation(h.ctx, tc.who, d.ID) + if !tc.ok { + code(t, err, tc.reason) + return + } + if err != nil || !ended.Revoked { + t.Fatalf("revoked %+v %v", ended, err) + } + if again, err := h.svc.RevokeDelegation(h.ctx, tc.who, d.ID); err != nil || !again.Revoked { + t.Fatalf("revoking twice answers the delegation as it stands: %+v %v", again, 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 new file mode 100644 index 00000000..25ccd114 --- /dev/null +++ b/libraries/authority-core/go/authority/host_installs_modules_test.go @@ -0,0 +1,185 @@ +package authority_test + +import ( + "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" +) + +// hostInstalled is a core whose host decides which organizations hold each +// module (Config.HostInstallsModules): its store answers the host's own +// records, which a test writes as the host would. +type hostInstalled struct { + *harness + svc *authority.Service + store *memstore.Store +} + +func newHostInstalled(t *testing.T) *hostInstalled { + t.Helper() + h := newHarness(t) + cfg := h.cfg + cfg.HostInstallsModules = true + store := memstore.New() + svc, err := authority.New(store, cfg) + if err != nil { + t.Fatal(err) + } + for _, org := range []string{acme, globex} { + store.PutOrganization(domain.Organization{ID: org, Name: org}) + } + 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: "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")}}}, + }); err != nil { + t.Fatal(err) + } + return &hostInstalled{harness: h, svc: svc, store: store} +} + +// record writes the host's own installation record of the store module. +func (h *hostInstalled) record(org, id string, status domain.Status, revision uint64) { + 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: status, Revision: revision}) + }); err != nil { + h.t.Fatal(err) + } +} + +func (h *hostInstalled) issue(w authority.Workload, org string) (authority.Issued, error) { + return h.svc.IssueForWorkload(h.ctx, w, authority.IssueRequest{Binding: "self", Organization: org}) +} + +func (h *hostInstalled) installations() []domain.Installation { + h.t.Helper() + var all []domain.Installation + if err := h.store.Read(h.ctx, func(tx authority.Tx) error { + var err error + all, err = tx.Installations("") + return err + }); err != nil { + h.t.Fatal(err) + } + return all +} + +// The core installs no module by itself: not at delivery, not for a new +// organization, not when reconciling; and the host's record is never retired +// by the core, by withdrawal or by host setup. +func TestAHostThatInstallsModulesIsTheOnlyOneThatDoes(t *testing.T) { + h := newHostInstalled(t) + if all := h.installations(); len(all) != 0 { + t.Fatalf("delivery installed %v", all) + } + ids, err := h.svc.OrganizationAdded(h.ctx, acme) + if err != nil || len(ids) != 0 { + t.Fatalf("a new organization was given %v (%v)", ids, err) + } + if err := h.svc.ReconcileOrganizations(h.ctx); err != nil { + t.Fatal(err) + } + if all := h.installations(); len(all) != 0 { + t.Fatalf("reconciling installed %v", all) + } + _, err = h.svc.OrganizationAdded(h.ctx, "nobody") + code(t, err, domain.NotFound) + + h.record(acme, "host-1", domain.Active, 1) + // A redelivery that narrows the module raises the host's record's + // revision, so what was issued under the wider module ends. + if _, err := h.svc.ApplyModule(h.ctx, domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 2, + ResourceKinds: []*basev0.WorkScopeV1{sc(kind, "read")}, + Bindings: map[string]domain.Binding{"self": {ID: "self", Audience: "store", + InvokeScopes: []*basev0.WorkScopeV1{sc(kind, "read")}, HeadlessScopes: []*basev0.WorkScopeV1{sc(kind, "read")}}}, + }); err != nil { + t.Fatal(err) + } + if all := h.installations(); len(all) != 1 || all[0].Revision != 2 || all[0].Status != domain.Active { + t.Fatalf("a narrowing left the host's record at %v", all) + } + err = h.svc.UninstallAsHost(h.ctx, acme, "host-1") + code(t, err, domain.Denied) + if err := h.svc.WithdrawModule(h.ctx, "store"); err != nil { + t.Fatal(err) + } + all := h.installations() + if len(all) != 1 || all[0].Status != domain.Active || all[0].Revision != 2 { + t.Fatalf("the host's record moved: %v", all) + } +} + +// A workload is issued only where its host recorded an installation, never +// past the organization its credential is bound to, and nothing once the host +// retires the installation: its capabilities fail their next check. +func TestAWorkloadIsIssuedOnlyWhereItsHostInstalledIt(t *testing.T) { + store := authority.Workload{Module: "store"} + for _, tc := range []struct { + name string + setup func(h *hostInstalled) + w authority.Workload + org string + want domain.Code + issued bool + }{ + {name: "no installation anywhere", w: store, org: acme, want: domain.Precondition}, + {name: "no installation and no organization named", w: store, want: domain.Denied}, + {name: "installed elsewhere", setup: func(h *hostInstalled) { h.record(globex, "host-g", domain.Active, 1) }, w: store, org: acme, want: domain.Precondition}, + {name: "installed here", setup: func(h *hostInstalled) { h.record(acme, "host-1", domain.Active, 1) }, w: store, org: acme, issued: true}, + {name: "the only installation, no organization named", setup: func(h *hostInstalled) { h.record(acme, "host-1", domain.Active, 1) }, w: store, issued: true}, + {name: "retired by the host", setup: func(h *hostInstalled) { h.record(acme, "host-1", domain.Revoked, 2) }, w: store, org: acme, want: domain.Precondition}, + {name: "bound to another organization", setup: func(h *hostInstalled) { h.record(acme, "host-1", domain.Active, 1) }, + w: authority.Workload{Module: "store", Organization: globex}, org: acme, want: domain.Denied}, + {name: "bound here, organization implied", setup: func(h *hostInstalled) { h.record(acme, "host-1", domain.Active, 1) }, + w: authority.Workload{Module: "store", Organization: acme}, issued: true}, + {name: "bound to an organization that has no installation", setup: func(h *hostInstalled) { h.record(acme, "host-1", domain.Active, 1) }, + w: authority.Workload{Module: "store", Organization: globex}, want: domain.Precondition}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHostInstalled(t) + if tc.setup != nil { + tc.setup(h) + } + issued, err := h.issue(tc.w, tc.org) + if !tc.issued { + code(t, err, tc.want) + return + } + if err != nil { + t.Fatal(err) + } + if got := issued.Context.GetTenantId(); got != acme { + t.Fatalf("issued at %q", got) + } + if _, _, err := h.svc.Check(h.ctx, issued.Token, "store"); err != nil { + t.Fatal(err) + } + }) + } + + t.Run("a capability ends when the host retires the installation, and stays ended after a reinstall", func(t *testing.T) { + h := newHostInstalled(t) + h.record(acme, "host-1", domain.Active, 1) + issued, err := h.issue(store, acme) + if err != nil { + t.Fatal(err) + } + h.record(acme, "host-1", domain.Revoked, 2) + if _, _, err := h.svc.Check(h.ctx, issued.Token, "store"); err == nil { + t.Fatal("a capability survived its installation's retirement") + } + h.record(acme, "host-2", domain.Active, 1) + if _, _, err := h.svc.Check(h.ctx, issued.Token, "store"); err == nil { + t.Fatal("a reinstall revived a capability sealed to the retired installation") + } + if _, err := h.issue(store, acme); err != nil { + t.Fatal(err) + } + }) +} diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index 4adde2a2..d96bbab6 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -32,8 +32,9 @@ type Issued struct { // held is what a person holds at an audience: at a solution, the scopes of the // roles granted them on its installation, owned by its principal; at a -// module, their organization roles within what it serves. -func held(tx Tx, p domain.Person, audience domain.Module) (Scopes, error) { +// module, their organization roles within what it serves, resource-scoped +// roles holding the ids their matchers select of named (decide.OrgScopes). +func held(tx Tx, p domain.Person, audience domain.Module, named Scopes) (Scopes, error) { if audience.Kind == domain.KindSolution { inst, err := installationOf(tx, p.Organization, audience.Name) if errors.Is(err, domain.ErrNotFound) { @@ -56,7 +57,7 @@ func held(tx Tx, p domain.Person, audience domain.Module) (Scopes, error) { if err != nil { return nil, err } - return decide.OrgScopes(roles, audience), nil + return decide.OrgScopes(roles, audience, named), nil } // solutionCeiling intersects the current roles of each solution the verified @@ -101,18 +102,18 @@ func solutionCeiling(tx Tx, p domain.Person, principals []string) (ceiling Scope // throughSolutions is what a person holds at audience on a path through // principals: the solutions' roles alone when a solution is on the path // (confined to its principal's data), the person's own grants otherwise. -func throughSolutions(tx Tx, p domain.Person, audience domain.Module, principals []string) (h, ceiling Scopes, bounded bool, err error) { +func throughSolutions(tx Tx, p domain.Person, audience domain.Module, principals []string, named Scopes) (h, ceiling Scopes, bounded bool, err error) { if ceiling, bounded, err = solutionCeiling(tx, p, principals); err != nil { return } if !bounded { - h, err = held(tx, p, audience) + h, err = held(tx, p, audience, named) return } h = scope.Intersect(ceiling, audience.ResourceKinds) if audience.Kind == domain.KindSolution { var target Scopes - if target, err = held(tx, p, audience); err != nil { + if target, err = held(tx, p, audience, named); err != nil { return } h = scope.Intersect(h, target) @@ -120,6 +121,16 @@ func throughSolutions(tx Tx, p domain.Person, audience domain.Module, principals return } +// namedBy is what a hop decides about: its ask and every scope its parent +// carries, so a prefix or a pattern holds the ids the parent was issued on. +func namedBy(asked Scopes, parent *basev0.WorkContextV1) Scopes { + named := append(slices.Clone(asked), parent.GetAuthorityScopes()...) + for _, actor := range parent.GetActorChain() { + named = append(named, actor.GetGrantedScopes()...) + } + return named +} + // solutionPrincipals are the current actor and the services of a verified chain. func solutionPrincipals(wc *basev0.WorkContextV1, current string) []string { out := []string{current} @@ -181,7 +192,7 @@ func (s *Service) IssueForPerson(ctx context.Context, who Person, r IssueRequest if audience, err = tx.Module(r.Audience); err != nil { return notFound(err) } - p, err := tx.Person(who.ID) + p, err := tx.Member(who.Organization, who.ID) if err != nil || p.Organization != who.Organization { return domain.Refuse("the session's person is no longer a member of %s", who.Organization) } @@ -192,7 +203,7 @@ func (s *Service) IssueForPerson(ctx context.Context, who Person, r IssueRequest if f, err = s.src.fenceIn(tx, p.Organization, p.ID, inst.ID); err != nil { return err } - if heldHere, err = held(tx, p, audience); err != nil { + if heldHere, err = held(tx, p, audience, r.Scopes); err != nil { return err } if audience.Kind == domain.KindSolution && !audience.OwnerAware { @@ -210,7 +221,7 @@ func (s *Service) IssueForPerson(ctx context.Context, who Person, r IssueRequest if err != nil { return notFound(err) } - there, _, _, err := throughSolutions(tx, p, target, []string{audience.Principal}) + there, _, _, err := throughSolutions(tx, p, target, []string{audience.Principal}, r.Scopes) if err != nil { return err } @@ -257,6 +268,13 @@ func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueReque return notFound(err) } org = r.Organization + if w.Organization != "" { + // A credential bound to one organization acts there and nowhere else. + if org != "" && org != w.Organization { + return domain.Refuse("workload %s is bound to organization %s; it is issued nothing at %s", w.Module, w.Organization, org) + } + org = w.Organization + } if org == "" { var orgs []string all, err := tx.Installations("") @@ -291,7 +309,7 @@ func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueReque return err } inst, err := installationOf(tx, org, module.Name) - if err != nil { + if err != nil || inst.Status != domain.Active { return domain.NotNow("%s is not installed at %s", module.Name, org) } instID = inst.ID @@ -475,11 +493,11 @@ func (s *Service) IssueChild(ctx context.Context, parent string, actor Workload, bounded := false switch claims.GetOwnerPrincipalKind() { case "human": - p, err := tx.Person(claims.GetOwnerPrincipalId()) + p, err := tx.Member(tenant, claims.GetOwnerPrincipalId()) if err != nil { return domain.Refuse("the owner %s is no longer a member", claims.GetOwnerPrincipalId()) } - if heldHere, sourceCeiling, bounded, err = throughSolutions(tx, p, audience, solutionPrincipals(claims, am.Principal)); err != nil { + if heldHere, sourceCeiling, bounded, err = throughSolutions(tx, p, audience, solutionPrincipals(claims, am.Principal), namedBy(r.Scopes, claims)); err != nil { return err } case "agent": diff --git a/libraries/authority-core/go/authority/journal_test.go b/libraries/authority-core/go/authority/journal_test.go new file mode 100644 index 00000000..ac9272e7 --- /dev/null +++ b/libraries/authority-core/go/authority/journal_test.go @@ -0,0 +1,97 @@ +package authority_test + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// Every change of authority is journaled in the transaction that makes it, +// naming where it happened, who acted and what changed; a journal that +// refuses aborts the change. +func TestEveryChangeIsJournaledInItsOwnTransaction(t *testing.T) { + h := newHarness(t) + var events []domain.Event + refuse := false + cfg := h.cfg + cfg.Journal = func(_ context.Context, _ authority.Tx, e domain.Event) error { + if refuse { + return errors.New("the journal is unavailable") + } + events = append(events, e) + return nil + } + svc, err := authority.New(h.store, cfg) + if err != nil { + t.Fatal(err) + } + alex := h.person("alex") + inst := h.inst(acme, "a").ID + viewer := authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", PersonID: "maya"} + steps := []struct { + name string + do func() error + want domain.Event + }{ + {"a role granted", func() error { _, err := svc.GrantRole(h.ctx, alex, viewer); return err }, + domain.Event{Kind: domain.RoleGranted, Organization: acme, Actor: "alex", Subject: inst}}, + {"a role revoked", func() error { _, err := svc.RevokeRole(h.ctx, alex, viewer); return err }, + domain.Event{Kind: domain.RoleRevoked, Organization: acme, Actor: "alex", Subject: inst}}, + {"a delegation granted", func() error { + _, err := svc.Delegate(h.ctx, h.person("rob"), "store", []*basev0.WorkScopeV1{owned(kind, "", []string{"read"}, "store-x")}, "a purpose") + return err + }, domain.Event{Kind: domain.DelegationGranted, Organization: acme, Actor: "rob"}}, + {"an installation uninstalled", func() error { return svc.Uninstall(h.ctx, alex, acme, inst) }, + domain.Event{Kind: domain.InstallationUninstalled, Organization: acme, Actor: "alex", Subject: inst}}, + {"an installation requested and activated", func() error { + _, err := svc.Install(h.ctx, alex, acme, "a", "author", "alex", h.approval(acme, "a"), nil) + return err + }, domain.Event{Kind: domain.InstallationActivated, Organization: acme, Actor: "alex", Subject: inst}}, + {"a module applied", func() error { + m := solution("d", true) + _, err := svc.ApplyModule(h.ctx, m) + return err + }, domain.Event{Kind: domain.ModuleApplied, Subject: "d"}}, + {"a module withdrawn", func() error { return svc.WithdrawModule(h.ctx, "d") }, domain.Event{Kind: domain.ModuleWithdrawn, Subject: "d"}}, + } + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + before := len(events) + if err := step.do(); err != nil { + t.Fatal(err) + } + if len(events) == before { + t.Fatal("nothing was journaled") + } + got := events[len(events)-1] + if got.Kind != step.want.Kind || got.Organization != step.want.Organization || got.Actor != step.want.Actor || + (step.want.Subject != "" && got.Subject != step.want.Subject) { + t.Fatalf("journaled %+v, want %+v", got, step.want) + } + }) + } + if !slices.ContainsFunc(events, func(e domain.Event) bool { return e.Kind == domain.InstallationRequested }) { + t.Fatal("the pending attempt was not journaled") + } + + t.Run("a journal that refuses aborts the change", func(t *testing.T) { + refuse = true + defer func() { refuse = false }() + _, err := svc.GrantRole(h.ctx, alex, authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", PersonID: "remi"}) + if err == nil { + t.Fatal("the grant committed without its journal entry") + } + grants, err := h.svc.ListRoleGrants(h.ctx, alex, acme, inst) + if err != nil { + t.Fatal(err) + } + if slices.ContainsFunc(grants, func(g domain.RoleGrant) bool { return g.PersonID == "remi" }) { + t.Fatal("the refused journal left the grant behind") + } + }) +} diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go index 91457e4c..310edf3f 100644 --- a/libraries/authority-core/go/authority/lifecycle.go +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -3,6 +3,7 @@ package authority import ( "context" "errors" + "fmt" "maps" "slices" @@ -12,13 +13,15 @@ import ( ) // ApplyModule records a delivered module at a newer generation and holds a -// module installation at every organization (a solution is installed +// module installation at every organization, unless the host installs +// modules itself (Config.HostInstallsModules; a solution is installed // explicitly). A narrowing invalidates the capabilities issued before it; a // withdrawn owner-aware declaration refuses its owner-selected ones for good. func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, error) { if err := decide.ValidateModule(m); err != nil { return nil, err } + m.ResourceKinds = decide.Served(m) var ids []string err := s.store.Write(ctx, func(tx Tx) error { ids = nil // a retried transaction starts over @@ -95,7 +98,10 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e if err := tx.PutModule(m); err != nil { return err } - if m.Kind == domain.KindSolution { + if err := s.journal(ctx, tx, domain.Event{Kind: domain.ModuleApplied, Subject: m.Name, Detail: map[string]string{"principal": m.Principal, "kind": m.Kind, "generation": fmt.Sprint(m.Generation)}}); err != nil { + return err + } + if m.Kind == domain.KindSolution || s.cfg.HostInstallsModules { return nil } orgs, err := tx.Organizations() @@ -131,6 +137,95 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e return ids, err } +// OrganizationAdded holds every composed module installed at an organization +// the host created after those modules were delivered, as ApplyModule holds +// them at every organization that existed then: the order of delivery and +// sign-up never decides whether an organization may use a module. It is +// idempotent, and a module installation an administrator or a withdrawal +// retired stays retired. It answers the organization's module installations. +func (s *Service) OrganizationAdded(ctx context.Context, org string) ([]string, error) { + var ids []string + err := s.store.Write(ctx, func(tx Tx) error { + ids = nil + if _, err := tx.Organization(org); err != nil { + return notFound(err) + } + if s.cfg.HostInstallsModules { + return nil // the host installs a module where its administrators consent + } + modules, err := tx.Modules() + if err != nil { + return err + } + for _, m := range modules { + if m.Kind == domain.KindSolution { + continue + } + id := s.cfg.InstallationID(org, m.Name) + if _, err := tx.Installation(id); errors.Is(err, domain.ErrNotFound) { + if err := tx.PutInstallation(domain.Installation{ID: id, Organization: org, Target: m.Name, Status: domain.Active, Revision: 1}); err != nil { + return err + } + } else if err != nil { + return err + } + ids = append(ids, id) + } + slices.Sort(ids) + return nil + }) + return ids, err +} + +// ReconcileOrganizations holds every composed module at every organization +// (OrganizationAdded at each), and does nothing when the host installs modules: what a host runs when it starts, so an +// organization created while the authority could not record it still holds +// the modules it may use. +func (s *Service) ReconcileOrganizations(ctx context.Context) error { + if s.cfg.HostInstallsModules { + return nil // nothing is installed by the core to reconcile + } + // One read finds the organizations missing a module installation, so a + // host with many organizations writes only where something is missing. + var missing []string + if err := s.store.Read(ctx, func(tx Tx) error { + missing = nil + orgs, err := tx.Organizations() + if err != nil { + return err + } + modules, err := tx.Modules() + if err != nil { + return err + } + all, err := tx.Installations("") + if err != nil { + return err + } + held := map[string]bool{} + for _, inst := range all { + held[inst.ID] = true + } + for _, org := range orgs { + for _, m := range modules { + if m.Kind != domain.KindSolution && !held[s.cfg.InstallationID(org.ID, m.Name)] { + missing = append(missing, org.ID) + break + } + } + } + return nil + }); err != nil { + return err + } + for _, org := range missing { + if _, err := s.OrganizationAdded(ctx, org); err != nil { + return fmt.Errorf("organization %s: %w", org, err) + } + } + return nil +} + // WithdrawModule removes a module: its installations are revoked and the // delegations granted to it end. func (s *Service) WithdrawModule(ctx context.Context, name string) error { @@ -139,8 +234,13 @@ func (s *Service) WithdrawModule(ctx context.Context, name string) error { if err != nil { return err } + withdrawn, err := tx.ModuleNamed(name) + if err != nil { + return notFound(err) + } + hostOwned := s.cfg.HostInstallsModules && withdrawn.Kind != domain.KindSolution for _, inst := range all { - if inst.Target == name && inst.Status != domain.Revoked { + if inst.Target == name && inst.Status != domain.Revoked && !hostOwned { if err := s.revoke(tx, inst); err != nil { return err } @@ -161,13 +261,13 @@ func (s *Service) WithdrawModule(ctx context.Context, name string) error { } } } - m, err := tx.ModuleNamed(name) - if err != nil { - return notFound(err) - } + m := withdrawn if err := tx.PutTombstone(domain.Tombstone{Name: m.Name, Principal: m.Principal, Generation: m.Generation}); err != nil { return err } + if err := s.journal(ctx, tx, domain.Event{Kind: domain.ModuleWithdrawn, Subject: m.Name, Detail: map[string]string{"principal": m.Principal, "generation": fmt.Sprint(m.Generation)}}); err != nil { + return err + } return tx.DeleteModule(name) }) } @@ -229,7 +329,7 @@ func (s *Service) install(ctx context.Context, authorize func(Tx) error, caller if _, ok := m.Roles[agentRole]; !ok { return domain.Missing("role %q is not one %s declares", agentRole, target) } - p, err := tx.Person(installedBy) + p, err := tx.Member(org, installedBy) if err != nil && !errors.Is(err, domain.ErrNotFound) { return err } @@ -269,13 +369,16 @@ func (s *Service) install(ctx context.Context, authorize func(Tx) error, caller return err } inst = existing - return tx.PutInstallation(existing) + if err := tx.PutInstallation(existing); err != nil { + return err + } + return s.journal(ctx, tx, requested(caller, existing)) } inst = existing return nil } if err == nil && existing.Status != domain.Revoked { - return domain.Refuse("%s is already installed at %s", target, org) + return &domain.Refusal{Code: domain.Denied, Cause: domain.ErrAlreadyInstalled, Reason: fmt.Sprintf("%s is already installed at %s", target, org)} } principal := s.cfg.PrincipalID(org, target) if _, err := tx.Module(principal); err == nil { @@ -306,7 +409,10 @@ func (s *Service) install(ctx context.Context, authorize func(Tx) error, caller if err := tx.DeleteRoleGrants(id); err != nil { return err } - return tx.PutInstallation(inst) + if err := tx.PutInstallation(inst); err != nil { + return err + } + return s.journal(ctx, tx, requested(caller, inst)) }) if err != nil { return inst, err @@ -349,24 +455,35 @@ func (s *Service) install(ctx context.Context, authorize func(Tx) error, caller } current.Status = domain.Active inst = current - return tx.PutInstallation(current) + if err := tx.PutInstallation(current); err != nil { + return err + } + return s.journal(ctx, tx, domain.Event{Kind: domain.InstallationActivated, Organization: current.Organization, Actor: caller.ID, Subject: current.ID, + Detail: map[string]string{"target": current.Target, "revision": fmt.Sprint(current.Revision)}}) }) return inst, err } +// requested is the journal entry of an installation attempt recorded pending. +func requested(caller Person, inst domain.Installation) domain.Event { + return domain.Event{Kind: domain.InstallationRequested, Organization: inst.Organization, Actor: caller.ID, Subject: inst.ID, + Detail: map[string]string{"target": inst.Target, "role": inst.AgentRole, "principal": inst.PrincipalID, "installed_by": inst.InstalledBy, + "revision": fmt.Sprint(inst.Revision), "operation": inst.Consent.OperationID}} +} + // Uninstall disables a solution installation and its principal at once, clears // its grants and revokes capabilities sealed to it or its organization. The // caller must currently administer the organization. Module installations // belong to host composition and cannot be uninstalled by a person. func (s *Service) Uninstall(ctx context.Context, caller Person, organization, installationID string) error { return s.store.Write(ctx, func(tx Tx) error { - p, err := tx.Person(caller.ID) + p, err := memberAs(tx, caller, organization) if err != nil && !errors.Is(err, domain.ErrNotFound) { return err } // Authorize before reading the installation: unauthorized callers // cannot tell a present installation from an absent one. - if caller.ID == "" || err != nil || p.Organization != organization || !decide.IsAdministrator(p) { + if err != nil || !decide.IsAdministrator(p) { return decide.ForeignInstallation(installationID, organization) } inst, err := installationIn(tx, organization, installationID) @@ -378,7 +495,10 @@ func (s *Service) Uninstall(ctx context.Context, caller Person, organization, in if inst.PrincipalID == "" || inst.InstalledBy == "" { return domain.Refuse("%s is a module, installed by composition: it is withdrawn, never uninstalled", inst.Target) } - return s.revoke(tx, inst) + if err := s.revoke(tx, inst); err != nil { + return err + } + return s.journal(ctx, tx, uninstalled(caller.ID, inst)) }) } @@ -391,10 +511,23 @@ func (s *Service) UninstallAsHost(ctx context.Context, organization, installatio if err != nil { return err } - return s.revoke(tx, inst) + if s.cfg.HostInstallsModules && inst.PrincipalID == "" { + if m, err := tx.ModuleNamed(inst.Target); err == nil && m.Kind != domain.KindSolution { + return domain.Refuse("%s is a module installed by its host's consent; the host uninstalls it", inst.Target) + } + } + if err := s.revoke(tx, inst); err != nil { + return err + } + return s.journal(ctx, tx, uninstalled("", inst)) }) } +func uninstalled(actor string, inst domain.Installation) domain.Event { + return domain.Event{Kind: domain.InstallationUninstalled, Organization: inst.Organization, Actor: actor, Subject: inst.ID, + Detail: map[string]string{"target": inst.Target, "revision": fmt.Sprint(inst.Revision + 1)}} +} + func installationIn(tx Tx, organization, installationID string) (domain.Installation, error) { inst, err := tx.Installation(installationID) if errors.Is(err, domain.ErrNotFound) || (err == nil && inst.Organization != organization) { @@ -422,8 +555,8 @@ func (s *Service) revoke(tx Tx, inst domain.Installation) error { func (s *Service) Installations(ctx context.Context, caller Person, org string) ([]domain.Installation, error) { var out []domain.Installation err := s.store.Read(ctx, func(tx Tx) error { - p, err := tx.Person(caller.ID) - if err != nil || p.Organization != org || !decide.IsAdministrator(p) { + p, err := memberAs(tx, caller, org) + if err != nil || !decide.IsAdministrator(p) { return domain.Refuse("installation administration requires an administrator or owner of organization %q", org) } out, err = tx.Installations(org) @@ -431,3 +564,13 @@ func (s *Service) Installations(ctx context.Context, caller Person, org string) }) return out, err } + +// Composed answers a composed module by name, as its delivery declared it. +func (s *Service) Composed(ctx context.Context, name string) (domain.Module, error) { + var m domain.Module + err := s.store.Read(ctx, func(tx Tx) (err error) { + m, err = tx.ModuleNamed(name) + return notFound(err) + }) + return m, err +} diff --git a/libraries/authority-core/go/authority/membership_test.go b/libraries/authority-core/go/authority/membership_test.go new file mode 100644 index 00000000..3bd663c3 --- /dev/null +++ b/libraries/authority-core/go/authority/membership_test.go @@ -0,0 +1,87 @@ +package authority_test + +import ( + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// A person may belong to several organizations. What they hold, administer +// and may be granted is read from their membership of the organization the +// session was opened at, never from another one. +func TestAPersonOfTwoOrganizationsActsOnlyAsTheMemberOfTheSessions(t *testing.T) { + h := newHarness(t) + h.store.PutOrgRole(domain.OrgRole{Organization: globex, Name: "store-writer", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"write"}}}}) + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "store-plain-reader", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"read"}}}}) + h.store.PutPerson(domain.Person{ID: "pat", Organization: acme, Roles: []string{"store-plain-reader"}}) + h.store.PutPerson(domain.Person{ID: "pat", Organization: globex, Roles: []string{"admin", "store-writer"}}) + atAcme := authority.Person{ID: "pat", Organization: acme, SessionID: "s-acme"} + atGlobex := authority.Person{ID: "pat", Organization: globex, SessionID: "s-globex"} + read, write := []*basev0.WorkScopeV1{sc(kind, "read")}, []*basev0.WorkScopeV1{sc(kind, "write")} + + for _, tc := range []struct { + name string + who authority.Person + scopes []*basev0.WorkScopeV1 + allowed bool + }{ + {"acme's role at acme", atAcme, read, true}, + {"globex's role does not reach acme", atAcme, write, false}, + {"globex's role at globex", atGlobex, write, true}, + {"acme's role does not reach globex", atGlobex, read, false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := h.svc.IssueForPerson(h.ctx, tc.who, authority.IssueRequest{Audience: "store", Scopes: tc.scopes}) + if tc.allowed && err != nil { + t.Fatal(err) + } + if !tc.allowed { + code(t, err, domain.Denied) + } + }) + } + + t.Run("administration is the session organization's", func(t *testing.T) { + if _, err := h.svc.Installations(h.ctx, atGlobex, globex); err != nil { + t.Fatal(err) + } + _, err := h.svc.Installations(h.ctx, atAcme, globex) + refused(t, err, "requires an administrator") + _, err = h.svc.Installations(h.ctx, atAcme, acme) + refused(t, err, "requires an administrator") + _, err = h.svc.Review(h.ctx, atAcme, globex, "b") + refused(t, err, "requires an administrator") + _, err = h.svc.GrantRole(h.ctx, atAcme, authority.RoleRequest{Organization: globex, InstallationID: h.inst(globex, "a").ID, Role: "viewer", PersonID: "pat"}) + refused(t, err, "another organization") + }) + + t.Run("a role granted at one organization's installation counts there alone", func(t *testing.T) { + h.grant("a", "viewer", "pat") + if _, err := h.svc.IssueForPerson(h.ctx, atAcme, authority.IssueRequest{Audience: "a"}); err != nil { + t.Fatal(err) + } + _, err := h.svc.IssueForPerson(h.ctx, atGlobex, authority.IssueRequest{Audience: "a"}) + refused(t, err, "holds no role") + }) + + t.Run("a person is granted a role only as a member of the installation's organization", func(t *testing.T) { + _, err := h.svc.GrantRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, "a").ID, Role: "viewer", PersonID: "omar"}) + refused(t, err, "is not a member of") + }) + + t.Run("leaving one organization leaves the other membership standing", func(t *testing.T) { + h.store.DeleteMember(acme, "pat") + _, err := h.svc.IssueForPerson(h.ctx, atAcme, authority.IssueRequest{Audience: "store", Scopes: read}) + refused(t, err, "no longer a member") + if _, err := h.svc.IssueForPerson(h.ctx, atGlobex, authority.IssueRequest{Audience: "store", Scopes: write}); err != nil { + t.Fatal(err) + } + }) + + t.Run("a session with no organization acts nowhere", func(t *testing.T) { + _, err := h.svc.Installations(h.ctx, authority.Person{ID: "pat"}, globex) + refused(t, err, "requires an administrator") + }) +} diff --git a/libraries/authority-core/go/authority/organization_added_test.go b/libraries/authority-core/go/authority/organization_added_test.go new file mode 100644 index 00000000..c1f06bfd --- /dev/null +++ b/libraries/authority-core/go/authority/organization_added_test.go @@ -0,0 +1,114 @@ +package authority_test + +import ( + "errors" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// An organization created after the modules were delivered holds each of +// them as an organization that existed then does; a retired installation +// stays retired, and asking twice changes nothing. +func TestAnOrganizationAddedLaterHoldsEveryComposedModule(t *testing.T) { + h := newHarness(t) + const initech = "initech" + h.store.PutOrganization(domain.Organization{ID: initech, Name: initech}) + h.store.PutPerson(domain.Person{ID: "ivy", Organization: initech}) + h.store.PutOrgRole(domain.OrgRole{Organization: initech, Name: "reader", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"read"}}}}) + h.store.PutPerson(domain.Person{ID: "ivy", Organization: initech, Roles: []string{"reader"}}) + ivy := authority.Person{ID: "ivy", Organization: initech, SessionID: "s-ivy"} + _, err := h.svc.IssueForPerson(h.ctx, ivy, authority.IssueRequest{Audience: "store", Scopes: []*basev0.WorkScopeV1{sc(kind, "read")}}) + code(t, err, domain.Precondition) + + ids, err := h.svc.OrganizationAdded(h.ctx, initech) + if err != nil { + t.Fatal(err) + } + if len(ids) != 2 { // store and legacy-store; solutions are installed explicitly + t.Fatalf("module installations %v", ids) + } + if _, err := h.svc.IssueForPerson(h.ctx, ivy, authority.IssueRequest{Audience: "store", Scopes: []*basev0.WorkScopeV1{sc(kind, "read")}}); err != nil { + t.Fatal(err) + } + if err := h.svc.UninstallAsHost(h.ctx, initech, authority.StableInstallationID(initech, "store")); err != nil { + t.Fatal(err) + } + if _, err := h.svc.OrganizationAdded(h.ctx, initech); err != nil { + t.Fatal(err) + } + _, err = h.svc.IssueForPerson(h.ctx, ivy, authority.IssueRequest{Audience: "store", Scopes: []*basev0.WorkScopeV1{sc(kind, "read")}}) + code(t, err, domain.Precondition) + _, err = h.svc.OrganizationAdded(h.ctx, "nobody") + code(t, err, domain.NotFound) + + // Reconciling every organization holds what is missing and nothing more. + const hooli = "hooli" + h.store.PutOrganization(domain.Organization{ID: hooli, Name: hooli}) + if err := h.svc.ReconcileOrganizations(h.ctx); err != nil { + t.Fatal(err) + } + ids, err = h.svc.OrganizationAdded(h.ctx, hooli) + if err != nil || len(ids) != 2 { + t.Fatalf("reconciled module installations %v %v", ids, err) + } + _, err = h.svc.IssueForPerson(h.ctx, ivy, authority.IssueRequest{Audience: "store", Scopes: []*basev0.WorkScopeV1{sc(kind, "read")}}) + code(t, err, domain.Precondition) +} + +// The host's own setup grants a role as the organization's administrator +// would: on an active installation of that organization, a declared role, to +// a member or a team of it. +func TestTheHostGrantsARoleAsTheAdministratorWould(t *testing.T) { + h := newHarness(t) + inst := h.inst(acme, "a").ID + for _, tc := range []struct { + name string + r authority.RoleRequest + code domain.Code + }{ + {"another organization's installation", authority.RoleRequest{Organization: globex, InstallationID: inst, Role: "viewer", PersonID: "omar"}, domain.Denied}, + {"an undeclared role", authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "owner", PersonID: "maya"}, domain.NotFound}, + {"no subject", authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer"}, domain.Invalid}, + {"a person of another organization", authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", PersonID: "omar"}, domain.Denied}, + {"a team of another organization", authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", TeamID: "ops"}, domain.Denied}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := h.svc.GrantRoleAsHost(h.ctx, tc.r) + code(t, err, tc.code) + }) + } + if _, err := h.svc.GrantRoleAsHost(h.ctx, authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", PersonID: "maya"}); err != nil { + t.Fatal(err) + } + if _, err := h.root("maya", "a"); err != nil { + t.Fatalf("the granted role is held: %v", err) + } + if _, err := h.svc.GrantRoleAsHost(h.ctx, authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", TeamID: "team"}); err != nil { + t.Fatal(err) + } + if _, err := h.root("tam", "a"); err != nil { + t.Fatalf("the team's role is held: %v", err) + } +} + +func TestAComposedModuleReadsAsDelivered(t *testing.T) { + h := newHarness(t) + m, err := h.svc.Composed(h.ctx, "a") + if err != nil || m.Principal != "a" || m.Kind != domain.KindSolution || len(m.Roles) != 4 { + t.Fatalf("composed %+v %v", m, err) + } + _, err = h.svc.Composed(h.ctx, "absent") + code(t, err, domain.NotFound) +} + +func TestInstallingAnInstalledTargetNamesIt(t *testing.T) { + h := newHarness(t) + _, err := h.svc.InstallAsHost(h.ctx, acme, "a", "author", "alex", nil) + code(t, err, domain.Denied) + if !errors.Is(err, domain.ErrAlreadyInstalled) { + t.Fatalf("an installed target's refusal names it: %v", err) + } +} diff --git a/libraries/authority-core/go/authority/r152_test.go b/libraries/authority-core/go/authority/r152_test.go index d4218da4..346ae3cf 100644 --- a/libraries/authority-core/go/authority/r152_test.go +++ b/libraries/authority-core/go/authority/r152_test.go @@ -86,6 +86,12 @@ func (t lifecycleOutageTx) Person(id string) (domain.Person, error) { } return t.Tx.Person(id) } +func (t lifecycleOutageTx) Member(org, id string) (domain.Person, error) { + if t.fail == "person" { + return domain.Person{}, t.err + } + return t.Tx.Member(org, id) +} func (t lifecycleOutageTx) Installation(id string) (domain.Installation, error) { if t.fail == "installation" { return domain.Installation{}, t.err diff --git a/libraries/authority-core/go/authority/resource_scoped_role_test.go b/libraries/authority-core/go/authority/resource_scoped_role_test.go new file mode 100644 index 00000000..3404dafa --- /dev/null +++ b/libraries/authority-core/go/authority/resource_scoped_role_test.go @@ -0,0 +1,43 @@ +package authority_test + +import ( + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// Rob reads store-x alone through his organization role. What he is issued, +// and every hop below it, names store-x and nothing else of the kind: a +// module deciding from the token's claims never sees the whole kind. +func TestAResourceScopedOrganizationRoleIssuesItsIDsAlone(t *testing.T) { + h := newHarness(t) + read := []string{"read"} + ids := func(of ...string) *basev0.WorkScopeV1 { return owned(kind, "", read, of...) } + + root := h.must(h.root("rob", "store")) + exactly(t, root, kind, "", read, "store-x") + if err := h.check(root); err != nil { + t.Fatal(err) + } + grants, err := h.svc.ListResources(h.ctx, root.Token, "store", kind, "read") + if err != nil || len(grants) != 1 || grants[0].ID != "store-x" { + t.Fatalf("listed %v %v", grants, err) + } + + for name, ask := range map[string]*basev0.WorkScopeV1{ + "another id": ids("store-y"), + "a held and another": ids("store-x", "store-y"), + "the whole kind": sc(kind, "read"), + } { + t.Run("an ask naming "+name+" is refused", func(t *testing.T) { + _, err := h.root("rob", "store", ask) + refused(t, err, "widens") + }) + t.Run("a child naming "+name+" is refused", func(t *testing.T) { + _, err := h.hop(root, "self", ask) + refused(t, err, "widens") + }) + } + exactly(t, h.must(h.hop(root, "self")), kind, "", read, "store-x") + exactly(t, h.must(h.root("rob", "store", ids("store-x"))), kind, "", read, "store-x") +} diff --git a/libraries/authority-core/go/authority/roles.go b/libraries/authority-core/go/authority/roles.go index 9c59d4f9..767e3a4a 100644 --- a/libraries/authority-core/go/authority/roles.go +++ b/libraries/authority-core/go/authority/roles.go @@ -19,18 +19,23 @@ func (r RoleRequest) grant() domain.RoleGrant { return domain.RoleGrant{InstallationID: r.InstallationID, Role: r.Role, PersonID: r.PersonID, TeamID: r.TeamID} } -// decideRoleChange reads what decide.ChangeRole needs. -func decideRoleChange(tx Tx, caller Person, r RoleRequest, list, revoke bool) (domain.Installation, error) { +// decideRoleChange reads what decide.ChangeRole needs. A nil caller is the +// host's own setup, deciding as the organization's administrator would. +func decideRoleChange(tx Tx, caller *Person, r RoleRequest, list, revoke bool) (domain.Installation, error) { // A caller of another organization than the one named learns nothing of // it: the foreign answer comes before any installation is read. Then the // installation is resolved by (organization, id): another organization's // and an absent one answer alike, having done the same work. - c, err := tx.Person(caller.ID) - if err != nil && !errors.Is(err, domain.ErrNotFound) { - return domain.Installation{}, err - } - if caller.ID == "" || err != nil || c.Organization != r.Organization { - return domain.Installation{}, decide.ForeignInstallation(r.InstallationID, r.Organization) + c := domain.Person{Organization: r.Organization, Roles: []string{"admin"}} + if caller != nil { + var err error + c, err = memberAs(tx, *caller, r.Organization) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + return domain.Installation{}, err + } + if err != nil { + return domain.Installation{}, decide.ForeignInstallation(r.InstallationID, r.Organization) + } } inst, err := tx.Installation(r.InstallationID) if errors.Is(err, domain.ErrNotFound) || (err == nil && inst.Organization != r.Organization) { @@ -50,7 +55,7 @@ func decideRoleChange(tx Tx, caller Person, r RoleRequest, list, revoke bool) (d change := decide.RoleChange{RequestOrganization: r.Organization, RequestedID: r.InstallationID, Installation: inst, Solution: solution, Caller: c, CallerRoles: decide.HeldRoles(c, inst, grants), List: list, Revoke: revoke, Role: r.Role, PersonID: r.PersonID, TeamID: r.TeamID} if r.PersonID != "" { - if p, err := tx.Person(r.PersonID); err == nil { + if p, err := tx.Member(inst.Organization, r.PersonID); err == nil { change.Subject = &p } } @@ -70,10 +75,36 @@ func (s *Service) GrantRole(ctx context.Context, caller Person, r RoleRequest) ( var inst domain.Installation err := s.store.Write(ctx, func(tx Tx) error { var err error - if inst, err = decideRoleChange(tx, caller, r, false, false); err != nil { + if inst, err = decideRoleChange(tx, &caller, r, false, false); err != nil { + return err + } + if err := tx.PutRoleGrant(r.grant()); err != nil { + return err + } + return s.journal(ctx, tx, r.event(domain.RoleGranted, caller.ID)) + }) + return inst, err +} + +func (r RoleRequest) event(kind domain.EventKind, actor string) domain.Event { + return domain.Event{Kind: kind, Organization: r.Organization, Actor: actor, Subject: r.InstallationID, + Detail: map[string]string{"role": r.Role, "person": r.PersonID, "team": r.TeamID}} +} + +// GrantRoleAsHost is for trusted host setup (a fixture's installations), +// never a request handler: it grants a role with no caller, decided as +// GrantRole decides it for the organization's administrator. +func (s *Service) GrantRoleAsHost(ctx context.Context, r RoleRequest) (domain.Installation, error) { + var inst domain.Installation + err := s.store.Write(ctx, func(tx Tx) error { + var err error + if inst, err = decideRoleChange(tx, nil, r, false, false); err != nil { + return err + } + if err := tx.PutRoleGrant(r.grant()); err != nil { return err } - return tx.PutRoleGrant(r.grant()) + return s.journal(ctx, tx, r.event(domain.RoleGranted, "")) }) return inst, err } @@ -85,7 +116,7 @@ func (s *Service) RevokeRole(ctx context.Context, caller Person, r RoleRequest) var inst domain.Installation err := s.store.Write(ctx, func(tx Tx) error { var err error - if inst, err = decideRoleChange(tx, caller, r, false, true); err != nil { + if inst, err = decideRoleChange(tx, &caller, r, false, true); err != nil { return err } if err := tx.DeleteRoleGrant(r.grant()); err != nil { @@ -109,7 +140,7 @@ func (s *Service) RevokeRole(ctx context.Context, caller Person, r RoleRequest) return err } } - return nil + return s.journal(ctx, tx, r.event(domain.RoleRevoked, caller.ID)) }) return inst, err } @@ -118,7 +149,7 @@ func (s *Service) RevokeRole(ctx context.Context, caller Person, r RoleRequest) func (s *Service) ListRoleGrants(ctx context.Context, caller Person, organization, installationID string) ([]domain.RoleGrant, error) { var out []domain.RoleGrant err := s.store.Read(ctx, func(tx Tx) error { - if _, err := decideRoleChange(tx, caller, RoleRequest{Organization: organization, InstallationID: installationID}, true, false); err != nil { + if _, err := decideRoleChange(tx, &caller, RoleRequest{Organization: organization, InstallationID: installationID}, true, false); err != nil { return err } var err error @@ -134,14 +165,3 @@ func (s *Service) ListRoleGrants(ctx context.Context, caller Person, organizatio func (s *Service) PersonChanged(ctx context.Context, person string) error { return s.store.Write(ctx, func(tx Tx) error { return tx.BumpPersonEpoch(person) }) } - -// callerOrganization is the organization the caller belongs to now, read from -// the store. -func callerOrganization(tx Tx, caller Person) string { - if p, err := tx.Person(caller.ID); err == nil { - return p.Organization - } - // A caller the store no longer knows belongs to no organization: they get - // the foreign answer, whatever their session says. - return "" -} diff --git a/libraries/authority-core/go/authority/service.go b/libraries/authority-core/go/authority/service.go index 5be0cdcd..61457442 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -48,6 +48,19 @@ type Config struct { Replay workcontext.ReplayStore // Grants answers approval grants; nil refuses every grant capability. Grants workcontext.GrantSource + // Journal records each change of authority in the transaction that makes + // it; an error aborts the change. nil journals nothing. + Journal func(ctx context.Context, tx Tx, e domain.Event) error + // HostInstallsModules says the host decides which organizations hold each + // composed module, by its own administrators' consent, and its store + // answers those installations from the host's own records. The core then + // never installs, reactivates or revokes a module installation itself: + // delivery, a new organization and reconciliation install nothing, + // withdrawal leaves the host's records to the host, and UninstallAsHost + // refuses a module. It still raises a module installation's revision when + // a redelivery narrows the module, which the store applies to the host's + // record. false: every composed module is installed at every organization. + HostInstallsModules bool } var ( @@ -102,6 +115,14 @@ func New(store Store, cfg Config) (*Service, error) { return s, nil } +// journal records e in tx, when the host journals. +func (s *Service) journal(ctx context.Context, tx Tx, e domain.Event) error { + if s.cfg.Journal == nil { + return nil + } + return s.cfg.Journal(ctx, tx, e) +} + // Seals and Revisions answer core's verifier and minter from the store. func (s *Service) Seals() workcontext.SealSource { return s.src } func (s *Service) Revisions() workcontext.RevisionSource { return s.src } @@ -117,6 +138,10 @@ type Person struct { // Workload is an attested module workload. type Workload struct { Module string + // Organization is the organization the workload's credential is bound to, + // empty when it serves every organization that installed it. A workload + // bound to one is issued nothing at another. + Organization string } func (s *Service) verifier(ctx context.Context, audience string) (*workcontext.Verifier, error) { @@ -224,8 +249,8 @@ func (src sources) holds(tx Tx, principal string, inst domain.Installation) (boo } else if !errors.Is(err, domain.ErrNotFound) { return false, err } - if p, err := tx.Person(principal); err == nil { - return p.Organization == inst.Organization, nil + if _, err := tx.Member(inst.Organization, principal); err == nil { + return true, nil } else if !errors.Is(err, domain.ErrNotFound) { return false, err } @@ -332,16 +357,28 @@ func (src sources) OperationBinding(ctx context.Context, id string) (workcontext return out, err } -// installationOf is an organization's installation of target. +// 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. func installationOf(tx Tx, org, target string) (domain.Installation, error) { all, err := tx.Installations(org) if err != nil { return domain.Installation{}, err } - for _, inst := range all { - if inst.Target == target { + var found *domain.Installation + for i, inst := range all { + if inst.Target != target { + continue + } + if inst.Status == domain.Active { return inst, nil } + if found == nil { + found = &all[i] + } + } + if found != nil { + return *found, nil } return domain.Installation{}, fmt.Errorf("installation of %s at %s: %w", target, org, domain.ErrNotFound) } diff --git a/libraries/authority-core/go/authority/store.go b/libraries/authority-core/go/authority/store.go index ab370bca..82d5362b 100644 --- a/libraries/authority-core/go/authority/store.go +++ b/libraries/authority-core/go/authority/store.go @@ -27,7 +27,13 @@ var ErrConflict = errors.New("authority: the transaction conflicted with another type Tx interface { Organizations() ([]domain.Organization, error) Organization(id string) (domain.Organization, error) + // Person answers whether id names a person at all, in any organization. + // A person may belong to several organizations, so the authority reads + // nothing else from it: what a person holds is read through Member. Person(id string) (domain.Person, error) + // Member is a person as a member of one organization: their roles and + // teams there. A person who is not a member of it is ErrNotFound. + Member(organization, id string) (domain.Person, error) People(organization string) ([]domain.Person, error) OrgRole(organization, name string) (domain.OrgRole, error) diff --git a/libraries/authority-core/go/authority/transfer.go b/libraries/authority-core/go/authority/transfer.go index 174c9e7f..59ed1364 100644 --- a/libraries/authority-core/go/authority/transfer.go +++ b/libraries/authority-core/go/authority/transfer.go @@ -72,7 +72,7 @@ func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (Tra if t.OwnerKind != "human" { return decide.AuthorizeTransfer(t) } - p, err := tx.Person(wc.GetOwnerPrincipalId()) + p, err := tx.Member(wc.GetTenantId(), wc.GetOwnerPrincipalId()) if err != nil { return domain.Refuse("the owner %s is no longer a member", wc.GetOwnerPrincipalId()) } @@ -92,7 +92,7 @@ func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (Tra } else if !errors.Is(err, domain.ErrNotFound) { return err } - if fp, err := tx.Person(r.From); err == nil { + if fp, err := tx.Member(wc.GetTenantId(), r.From); err == nil { t.FromPerson = &fp } roles, err := orgRoles(tx, p) @@ -130,7 +130,8 @@ func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (Tra return err } decision = TransferDecision{PrincipalID: r.To, AuthorizedBy: p.ID} - return nil + return s.journal(ctx, tx, domain.Event{Kind: domain.ResourceTransferred, Organization: target.Organization, Actor: p.ID, Subject: target.ID, + Detail: map[string]string{"kind": r.Kind, "resource": r.ResourceID, "from": r.From, "to": r.To, "store": r.Store.Module}}) }) return decision, err } diff --git a/libraries/authority-core/go/decide/decide.go b/libraries/authority-core/go/decide/decide.go index e30025c7..c137347d 100644 --- a/libraries/authority-core/go/decide/decide.go +++ b/libraries/authority-core/go/decide/decide.go @@ -23,23 +23,72 @@ func IsAdministrator(p domain.Person) bool { // OrgScopes is what a person's organization roles grant at audience: the // union of their scopes (AnyKind spanning every served kind) within what the -// audience serves. Resource matchers are ListResources' (ResourceGrants). -func OrgScopes(roles []domain.OrgRole, audience domain.Module) Scopes { +// audience serves. A role scope naming no resource holds the whole kind; one +// naming resources holds exactly the ids its matchers select (heldIDs), never +// the whole kind, so Issue refuses an ask naming any other id and an empty +// ask carries those ids. named is what the decision is about: the ask, and +// the capability a hop or a delegation derives from. +func OrgScopes(roles []domain.OrgRole, audience domain.Module, named Scopes) Scopes { var held Scopes for _, role := range roles { for _, sc := range role.Scopes { + kinds := []string{sc.Kind} if sc.Kind == scope.AnyKind { + kinds = kinds[:0] for _, kind := range audience.ResourceKinds { - held = append(held, &basev0.WorkScopeV1{ResourceKind: kind.GetResourceKind(), Actions: slices.Clone(sc.Actions)}) + kinds = append(kinds, kind.GetResourceKind()) } - continue } - held = append(held, &basev0.WorkScopeV1{ResourceKind: sc.Kind, Actions: slices.Clone(sc.Actions)}) + for _, kind := range kinds { + ids, all := heldIDs(sc.Resources, kind, named) + if !all && len(ids) == 0 { + // No row: a row naming no id is the whole kind. + continue + } + held = append(held, &basev0.WorkScopeV1{ResourceKind: kind, Actions: slices.Clone(sc.Actions), ResourceIds: ids}) + } } } return scope.Intersect(scope.Normalize(held), audience.ResourceKinds) } +// heldIDs is what a role scope's matchers hold of kind: every resource when +// they name none or All; otherwise each ID matcher's id, and of a prefix or a +// pattern only the ids named on kind that it matches. A token cannot carry a +// prefix or a pattern, and carrying it as the whole kind would hand a module +// reading the token every resource of the kind, so an ask naming no id holds +// none of it: the person names the ids they act on. Matchers that do not +// compile hold nothing. +func heldIDs(grants []resources.Grant, kind string, named Scopes) (ids []string, all bool) { + if len(grants) == 0 { + return nil, true + } + m, err := resources.Compile(grants) + if err != nil { + return nil, false + } + if m.All() { + return nil, true + } + for _, g := range grants { + if g.ID != "" { + ids = append(ids, g.ID) + } + } + for _, sc := range named { + if sc.GetResourceKind() != kind { + continue + } + for _, id := range sc.GetResourceIds() { + if m.Matches(id) { + ids = append(ids, id) + } + } + } + slices.Sort(ids) + return slices.Compact(ids), false +} + // ResourceGrants are the resource matchers of every organization-role scope // holding action on kind, or every resource for a scope that names none. func ResourceGrants(roles []domain.OrgRole, kind, action string) []resources.Grant { diff --git a/libraries/authority-core/go/decide/decide_test.go b/libraries/authority-core/go/decide/decide_test.go index 94fca055..8c4049e2 100644 --- a/libraries/authority-core/go/decide/decide_test.go +++ b/libraries/authority-core/go/decide/decide_test.go @@ -7,6 +7,7 @@ import ( "github.com/codefly-dev/authority/libraries/authority-core/go/decide" "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" resources "github.com/codefly-dev/authority/libraries/resources/go" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) @@ -217,7 +218,8 @@ func TestATransferIsDecidedInOrder(t *testing.T) { func TestOrganizationGrantsAndModules(t *testing.T) { audience := domain.Module{ResourceKinds: []*basev0.WorkScopeV1{sc("doc", "read", "admin")}} roles := []domain.OrgRole{{Name: "r", Scopes: []domain.RoleScope{{Kind: "*", Actions: []string{"read"}}, {Kind: "doc", Actions: []string{"admin"}, Resources: []resources.Grant{{ID: "x"}}}}}} - if got := decide.OrgScopes(roles, audience); len(got) != 1 || len(got[0].GetActions()) != 2 { + // read holds the whole kind; admin holds x alone, never the kind. + if got := decide.OrgScopes(roles, audience, nil); !scope.Same(got, []*basev0.WorkScopeV1{sc("doc", "read"), owned("doc", "", []string{"admin"}, "x")}) { t.Fatalf("org scopes %v", got) } if got := decide.ResourceGrants(roles, "doc", "admin"); len(got) != 1 || got[0].ID != "x" { diff --git a/libraries/authority-core/go/decide/manifest.go b/libraries/authority-core/go/decide/manifest.go index 9e9324cd..828c5e60 100644 --- a/libraries/authority-core/go/decide/manifest.go +++ b/libraries/authority-core/go/decide/manifest.go @@ -8,6 +8,7 @@ import ( "github.com/codefly-dev/authority/libraries/authority-core/go/domain" "github.com/codefly-dev/authority/libraries/authority-core/go/scope" delivery "github.com/codefly-dev/authority/libraries/delivery/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) // ValidateModule holds a delivered module to its contract: a principal, a @@ -15,6 +16,13 @@ import ( // a module declares no roles; a solution declares installer, and every role // stays within what it serves or consumes through invoke/lookup bindings. func ValidateModule(m domain.Module) error { + if err := validateModule(m); err != nil { + return err + } + return noOwners(m) +} + +func validateModule(m domain.Module) error { if m.Name == "" || m.Principal == "" || m.Generation == 0 { return domain.Refuse("a manifest names its module, its principal and a generation") } @@ -119,3 +127,58 @@ func Narrowed(previous, next domain.Module) bool { } return false } + +// noOwners refuses a delivery naming a resource owner where none belongs: in +// what a module serves, in a binding's scopes, in a role or in a method's +// requirement. An owner is the authority's to select per installation; read +// owner-blind, it would widen to the whole kind, so it is refused by name. +func noOwners(m domain.Module) error { + check := func(where string, scopes Scopes) error { + for _, sc := range scopes { + if sc.GetResourceOwnerId() != "" { + return domain.Refuse("%s: %s names resource owner %q; a delivery never selects an owner", where, sc.GetResourceKind(), sc.GetResourceOwnerId()) + } + } + return nil + } + if err := check(m.Name+" serves", m.ResourceKinds); err != nil { + return err + } + for _, id := range slices.Sorted(maps.Keys(m.Bindings)) { + b := m.Bindings[id] + for _, scopes := range []Scopes{b.InvokeScopes, b.HeadlessScopes, b.LookupScopes} { + if err := check(m.Name+" binding "+id, scopes); err != nil { + return err + } + } + } + for _, name := range slices.Sorted(maps.Keys(m.Roles)) { + if err := check(m.Name+" role "+name, m.Roles[name]); err != nil { + return err + } + } + for _, name := range slices.Sorted(maps.Keys(m.OwnedRoles)) { + if err := check(m.Name+" installation-owned role "+name, m.OwnedRoles[name]); err != nil { + return err + } + } + for _, method := range slices.Sorted(maps.Keys(m.Methods)) { + if err := check(m.Name+" method "+method, m.Methods[method].Requires); err != nil { + return err + } + } + return nil +} + +// Served is what a module serves: the kinds it declares, joined to every +// (kind, action) its methods require of a call, read from its own method +// declarations. +func Served(m domain.Module) Scopes { + served := slices.Clone(m.ResourceKinds) + for _, method := range slices.Sorted(maps.Keys(m.Methods)) { + for _, required := range m.Methods[method].Requires { + served = append(served, &basev0.WorkScopeV1{ResourceKind: required.GetResourceKind(), Actions: slices.Clone(required.GetActions())}) + } + } + return scope.Normalize(served) +} diff --git a/libraries/authority-core/go/decide/method.go b/libraries/authority-core/go/decide/method.go new file mode 100644 index 00000000..6803c332 --- /dev/null +++ b/libraries/authority-core/go/decide/method.go @@ -0,0 +1,104 @@ +package decide + +import ( + "fmt" + "slices" + "strings" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + "github.com/codefly-dev/authority/libraries/hop/go/routing" +) + +// AdmitMethod applies what the module m (the audience a call is checked for) +// declares for method to the scopes the checked call holds. A method that m +// does not declare is NotFound; a module that declares no method policy, a +// method declaring nothing, or one declaring both that it requires scopes and +// that it requires none, is refused: an absent or ambiguous declaration is +// never read as permission. Method admission checks kinds and actions only: +// the module still checks each resource, and its owner, against the +// unchanged scopes. +// +// method is a full protobuf procedure, or an HTTP request " " +// admitted under the one route template "