From b666d2170258fefebb77d3fcff02be311089dcdc Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:10:40 -0400 Subject: [PATCH 01/16] feat(authority-core): one membership per organization, the delivery reading, method admission and a store contract, shared by every host A person may belong to several organizations: Tx.Member(org, id) is what the core reads what they hold from, and a caller acts only in their session's organization. The reading of a verified delivery (FromCore, ceilings, roles, installation-owned roles, methods, owner-aware, the document's pairing) moves from the local composition plugin into package delivered; method admission moves from the local state into decide.AdmitMethod and Service.CheckMethod; the owner refusal and the method-served kinds move into ApplyModule; module installations at an organization added later are Service.OrganizationAdded. Package storetest is the contract every Store meets, run here on memstore. Co-Authored-By: Claude Opus 5.5 --- .../authority-core/go/authority/check.go | 32 +- .../authority-core/go/authority/consent.go | 16 +- .../authority-core/go/authority/delegation.go | 4 +- .../authority-core/go/authority/issue.go | 4 +- .../authority-core/go/authority/lifecycle.go | 48 +- .../go/authority/membership_test.go | 87 +++ .../go/authority/organization_added_test.go | 45 ++ .../authority-core/go/authority/r152_test.go | 6 + .../authority-core/go/authority/roles.go | 16 +- .../authority-core/go/authority/service.go | 4 +- .../authority-core/go/authority/store.go | 6 + .../authority-core/go/authority/transfer.go | 4 +- .../authority-core/go/decide/manifest.go | 63 ++ libraries/authority-core/go/decide/method.go | 104 ++++ .../authority-core/go/decide/method_test.go | 145 +++++ .../authority-core/go/delivered/delivered.go | 217 +++++++ .../go/delivered/delivered_test.go | 171 ++++++ libraries/authority-core/go/go.mod | 38 +- libraries/authority-core/go/go.sum | 205 ++++++- .../authority-core/go/memstore/memstore.go | 43 +- .../go/storetest/memstore_test.go | 33 + .../authority-core/go/storetest/storetest.go | 577 ++++++++++++++++++ .../local/code/pkg/business/issuer/issuer.go | 13 +- .../pkg/business/issuer/parent_limits_test.go | 2 +- .../code/pkg/business/state/permissions.go | 2 +- .../local/code/pkg/business/state/state.go | 183 +----- .../local/code/plugins/composition/plugin.go | 195 +----- .../code/plugins/composition/plugin_test.go | 70 --- 28 files changed, 1879 insertions(+), 454 deletions(-) create mode 100644 libraries/authority-core/go/authority/membership_test.go create mode 100644 libraries/authority-core/go/authority/organization_added_test.go create mode 100644 libraries/authority-core/go/decide/method.go create mode 100644 libraries/authority-core/go/decide/method_test.go create mode 100644 libraries/authority-core/go/delivered/delivered.go create mode 100644 libraries/authority-core/go/delivered/delivered_test.go create mode 100644 libraries/authority-core/go/storetest/memstore_test.go create mode 100644 libraries/authority-core/go/storetest/storetest.go delete mode 100644 modules/authority/services/local/code/plugins/composition/plugin_test.go 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/delegation.go b/libraries/authority-core/go/authority/delegation.go index e737cdfa..bad602c4 100644 --- a/libraries/authority-core/go/authority/delegation.go +++ b/libraries/authority-core/go/authority/delegation.go @@ -15,7 +15,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) } @@ -138,7 +138,7 @@ 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) } diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index 4adde2a2..72b23cbf 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -181,7 +181,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) } @@ -475,7 +475,7 @@ 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()) } diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go index 91457e4c..d34e3310 100644 --- a/libraries/authority-core/go/authority/lifecycle.go +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -19,6 +19,7 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e 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 @@ -131,6 +132,43 @@ 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) + } + 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 +} + // 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 { @@ -229,7 +267,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 } @@ -360,13 +398,13 @@ func (s *Service) install(ctx context.Context, authorize func(Tx) error, caller // 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) @@ -422,8 +460,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) 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..909383d6 --- /dev/null +++ b/libraries/authority-core/go/authority/organization_added_test.go @@ -0,0 +1,45 @@ +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" +) + +// 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) +} 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/roles.go b/libraries/authority-core/go/authority/roles.go index 9c59d4f9..deb45d06 100644 --- a/libraries/authority-core/go/authority/roles.go +++ b/libraries/authority-core/go/authority/roles.go @@ -25,11 +25,11 @@ func decideRoleChange(tx Tx, caller Person, r RoleRequest, list, revoke bool) (d // 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) + c, err := memberAs(tx, caller, r.Organization) if err != nil && !errors.Is(err, domain.ErrNotFound) { return domain.Installation{}, err } - if caller.ID == "" || err != nil || c.Organization != r.Organization { + if err != nil { return domain.Installation{}, decide.ForeignInstallation(r.InstallationID, r.Organization) } inst, err := tx.Installation(r.InstallationID) @@ -50,7 +50,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 } } @@ -135,13 +135,3 @@ 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..857cfb2a 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -224,8 +224,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 } 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..c004b8ac 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) 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 "