diff --git a/libraries/authority-core/go/authority/check.go b/libraries/authority-core/go/authority/check.go new file mode 100644 index 00000000..cc2dcfc5 --- /dev/null +++ b/libraries/authority-core/go/authority/check.go @@ -0,0 +1,168 @@ +package authority + +import ( + "context" + "errors" + "slices" + + "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" + "github.com/codefly-dev/core/workcontext" +) + +// Check verifies a capability for the audience the caller is, and refuses +// owner-selected scopes the audience can no longer honour: an audience that +// does not declare owner-aware checking now, or one that withdrew it after +// the capability was issued through a binding to it (never revived). +func (s *Service) Check(ctx context.Context, token, audience string) (*workcontext.Verified, uint64, error) { + verified, err := s.Verify(ctx, token, audience) + if errors.Is(err, workcontext.ErrRevoked) { + // Verified up to the revocation: name an owner-aware withdrawal, + // which core reports as a binding incarnation that moved. + if decoded, derr := workcontext.Decode(token); derr == nil { + wc := decoded.Context() + if scope.HasOwner(effectiveOf(wc)) && wc.GetOperationBinding().GetBindingId() != "" { + if current, berr := s.src.OperationBinding(ctx, wc.GetOperationBinding().GetBindingId()); berr == nil && current.Incarnation != wc.GetOperationBinding().GetIncarnation() { + return nil, 0, domain.Refuse("issued through a binding re-created since, or before %s last withdrew owner-aware checking; only a fresh capability carries owner-selected scopes", audience) + } + } + } + } + if err != nil { + return nil, 0, err + } + var revision uint64 + err = s.store.Read(ctx, func(tx Tx) error { + m, err := tx.Module(audience) + if err != nil { + return notFound(err) + } + if err := decide.OwnerAware(m, verified.EffectiveScopes()); err != nil { + return err + } + revision, err = tx.AuthorizationRevision(verified.Context().GetTenantId()) + return err + }) + if err != nil { + return nil, 0, err + } + return verified, revision, 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 { + resources.Grant + Owner string +} + +// ListResources answers which resources of kind a checked capability may act +// on with action, decided now: a person's through a solution are its roles on +// the principal's data, a person's own their organization roles' matchers, +// cut to any ids the capability names; a module's or an installation's own +// work is bounded by the capability alone. +func (s *Service) ListResources(ctx context.Context, token, audience, kind, action string) ([]Grant, error) { + if audience == "" || kind == "" || action == "" { + return nil, domain.Refuse("a resource question names the audience, a kind and an action") + } + verified, _, err := s.Check(ctx, token, audience) + if err != nil { + return nil, err + } + effective := verified.EffectiveScopes() + if !scope.HoldsAction(effective, kind, action) { + return nil, domain.Refuse("the capability holds no %s:%s", kind, action) + } + wc := verified.Context() + var out []Grant + err = s.store.Read(ctx, func(tx Tx) error { + rows := effective + if wc.GetOwnerPrincipalKind() == "human" { + p, err := tx.Person(wc.GetOwnerPrincipalId()) + if err != nil { + return domain.Refuse("the owner %s is no longer a member", wc.GetOwnerPrincipalId()) + } + ceiling, bounded, err := solutionCeiling(tx, p, solutionPrincipals(wc, wc.GetAudience())) + if err != nil { + return err + } + if bounded { + rows = scope.Intersect(effective, ceiling) + } else { + roles, err := orgRoles(tx, p) + if err != nil { + return err + } + out = narrow(decide.ResourceGrants(roles, kind, action), effective, kind, action) + return nil + } + } + out = grantsOf(rows, kind, action) + return nil + }) + return out, err +} + +func holds(sc *basev0.WorkScopeV1, kind, action string) bool { + return sc.GetResourceKind() == kind && (slices.Contains(sc.GetActions(), action) || slices.Contains(sc.GetActions(), scope.AnyAction)) +} + +// grantsOf projects the rows of kind holding action: their ids, or every +// resource, each qualified by the row's owner. +func grantsOf(rows Scopes, kind, action string) []Grant { + out := []Grant{} + for _, sc := range rows { + if !holds(sc, kind, action) { + continue + } + if len(sc.GetResourceIds()) == 0 { + out = append(out, Grant{Grant: resources.Grant{All: true}, Owner: sc.GetResourceOwnerId()}) + continue + } + for _, id := range sc.GetResourceIds() { + out = append(out, Grant{Grant: resources.Grant{ID: id}, Owner: sc.GetResourceOwnerId()}) + } + } + return out +} + +// narrow cuts an organization role's matchers to the ids the capability +// names, when it names any. +func narrow(matchers []resources.Grant, effective Scopes, kind, action string) []Grant { + var ids []string + for _, sc := range effective { + if !holds(sc, kind, action) { + continue + } + if len(sc.GetResourceIds()) == 0 { + out := make([]Grant, 0, len(matchers)) + for _, m := range matchers { + out = append(out, Grant{Grant: m}) + } + return out + } + ids = append(ids, sc.GetResourceIds()...) + } + covered, err := resources.Compile(matchers) + out := []Grant{} + if err != nil { + return out + } + for _, id := range ids { + if covered.Matches(id) { + out = append(out, Grant{Grant: resources.Grant{ID: id}}) + } + } + return out +} + +// effectiveOf is a context's effective scopes: its last actor's, else its own. +func effectiveOf(wc *basev0.WorkContextV1) Scopes { + if chain := wc.GetActorChain(); len(chain) > 0 { + return chain[len(chain)-1].GetGrantedScopes() + } + return wc.GetAuthorityScopes() +} diff --git a/libraries/authority-core/go/authority/child_intersection_test.go b/libraries/authority-core/go/authority/child_intersection_test.go new file mode 100644 index 00000000..fa5a60dd --- /dev/null +++ b/libraries/authority-core/go/authority/child_intersection_test.go @@ -0,0 +1,59 @@ +package authority_test + +import ( + "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" +) + +// A child is the owner's current grants intersected with the binding it is +// issued through: a scope the parent holds outside the binding never reaches +// the child, whatever the parent carried (rule A, r122 on runtime #311). +func TestAChildHoldsOnlyWhatTheBindingAndTheOwnersGrantsShare(t *testing.T) { + h := newHarness(t) + page := domain.Module{Name: "page", Principal: "page", Kind: domain.KindModule, Generation: 1, + ResourceKinds: []*basev0.WorkScopeV1{sc("page.entry", "read"), sc(kind, "read", "write"), sc("profiles", "read")}, + Bindings: map[string]domain.Binding{"store": {ID: "store", Audience: "store", InvokeScopes: []*basev0.WorkScopeV1{sc(kind, "read", "write")}}}} + if _, err := h.svc.ApplyModule(h.ctx, page); err != nil { + t.Fatal(err) + } + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "wide", Scopes: []domain.RoleScope{ + {Kind: "page.entry", Actions: []string{"read"}}, {Kind: kind, Actions: []string{"read", "write"}}, {Kind: "profiles", Actions: []string{"read"}}}}) + h.store.PutPerson(domain.Person{ID: "wren", Organization: acme, Roles: []string{"wide"}}) + + parent := h.must(h.root("wren", "page")) + if kinds := kindsOf(effective(parent)); !slices.Equal(kinds, []string{kind, "page.entry", "profiles"}) { + t.Fatalf("the parent holds %v; the test needs scopes outside the binding", kinds) + } + for name, ask := range map[string][]*basev0.WorkScopeV1{"everything held": nil, "the binding's kind": {sc(kind, "read", "write")}} { + t.Run(name, func(t *testing.T) { + child := h.must(h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "page"}, authority.IssueRequest{Binding: "store", Scopes: ask})) + exactly(t, child, kind, "", []string{"read", "write"}) + verified, _, err := h.svc.Check(h.ctx, child.Token, "store") + if err != nil { + t.Fatal(err) + } + if kinds := kindsOf(verified.EffectiveScopes()); !slices.Equal(kinds, []string{kind}) { + t.Fatalf("the checked child holds %v, want only %s", kinds, kind) + } + }) + } + t.Run("a scope outside the binding", func(t *testing.T) { + _, err := h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "page"}, authority.IssueRequest{Binding: "store", Scopes: []*basev0.WorkScopeV1{sc("profiles", "read")}}) + refused(t, err, "widens") + }) +} + +func kindsOf(scopes []*basev0.WorkScopeV1) []string { + var out []string + for _, s := range scopes { + if len(s.GetActions()) > 0 && !slices.Contains(out, s.GetResourceKind()) { + out = append(out, s.GetResourceKind()) + } + } + slices.Sort(out) + return out +} diff --git a/libraries/authority-core/go/authority/delegation.go b/libraries/authority-core/go/authority/delegation.go new file mode 100644 index 00000000..e737cdfa --- /dev/null +++ b/libraries/authority-core/go/authority/delegation.go @@ -0,0 +1,196 @@ +package authority + +import ( + "context" + + "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" + "github.com/codefly-dev/core/workcontext" +) + +// Delegate records a person's grant to a module of scopes they hold: at the +// module, or at an audience one of its bindings addresses (through a +// solution, its roles on its principal's data). +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) + if err != nil { + return notFound(err) + } + m, err := tx.Module(module) + if err != nil { + return notFound(err) + } + if len(scopes) == 0 { + return domain.Refuse("a delegation names what it grants") + } + holdings, err := held(tx, p, m) + if err != nil { + return err + } + for _, b := range m.Bindings { + target, err := tx.Module(b.Audience) + if err != nil { + continue + } + there, _, _, err := throughSolutions(tx, p, target, []string{m.Principal}) + if err != nil { + return err + } + if m.Kind != domain.KindSolution { + if there, err = held(tx, p, target); err != nil { + return err + } + } + holdings = append(holdings, scope.Intersect(there, b.InvokeScopes)...) + holdings = append(holdings, scope.Intersect(there, b.LookupScopes)...) + } + if !scope.Attenuates(scopes, holdings) { + 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) + }) + return d, err +} + +// RevokeDelegation ends a delegation; only its grantor may. 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 { + var err error + if d, err = tx.Delegation(id); err != nil { + return notFound(err) + } + if d.Grantor != who.ID { + return domain.Refuse("only the grantor of %s may revoke it", id) + } + if d.Revoked { + return nil + } + d.Revoked = true + if err := tx.PutDelegation(d); err != nil { + return err + } + return tx.BumpAuthorizationRevision(d.Organization) + }) + return d, err +} + +// Delegation reads one delegation for its module or its grantor; anyone else +// learns nothing, not even that it exists. Exactly one of who and module is set. +func (s *Service) Delegation(ctx context.Context, id string, who *Person, module *Workload) (domain.Delegation, error) { + var d domain.Delegation + err := s.store.Read(ctx, func(tx Tx) error { + var err error + d, err = tx.Delegation(id) + if err == nil && ((module != nil && module.Module == d.Module) || (who != nil && who.ID == d.Grantor)) { + return nil + } + return domain.Missing("delegation %q", id) + }) + return d, err +} + +// IssueForDelegation issues for a delegation's grantor through the module it +// was granted to: what was delegated, cut to what the grantor holds at the +// audience now and to the binding. No live capability is presented. +func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workload, r IssueRequest) (Issued, error) { + var d domain.Delegation + var am, audience domain.Module + var through, scopes Scopes + var f fence + var sealTo string + err := s.store.Read(ctx, func(tx Tx) error { + var err error + if d, err = tx.Delegation(id); err != nil { + return notFound(err) + } + if d.Revoked { + return domain.Refuse("delegation %s is revoked", id) + } + if am, err = tx.ModuleNamed(actor.Module); err != nil { + return notFound(err) + } + if am.Name != d.Module { + return domain.Refuse("delegation %s is granted to %s, not %s", id, d.Module, am.Name) + } + if r.Binding == "" { + return domain.Refuse("an issue from a delegation names the binding it exercises") + } + if err = s.audienceOf(tx, &r, actor.Module); err != nil { + return err + } + binding, ok := am.Bindings[r.Binding] + if !ok { + return domain.Missing("operation binding %q is not one %s declares", r.Binding, am.Name) + } + if audience, err = tx.Module(r.Audience); err != nil { + return notFound(err) + } + if binding.Audience != audience.Principal { + return domain.Refuse("binding %q is addressed to %s, not %s", r.Binding, binding.Audience, audience.Principal) + } + + if through, err = decide.ScopesFor(binding, r.Operation, "invoke"); err != nil { + return err + } + p, err := tx.Person(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}) + if err != nil { + return err + } + ceiling := scope.Intersect(scope.Intersect(d.Scopes, h), through) + if len(ceiling) == 0 { + return domain.Refuse("delegation %s grants nothing %s still holds at %s through binding %q", id, d.Grantor, audience.Name, r.Binding) + } + if scopes, err = decide.Issue(r.Scopes, ceiling); err != nil { + return err + } + if err := decide.OwnerAware(audience, scopes); err != nil { + return err + } + inst, err := installationOf(tx, d.Organization, am.Name) + if err != nil { + return domain.NotNow("%s is not installed at %s", am.Name, d.Organization) + } + sealTo = inst.ID + f, err = s.src.fenceIn(tx, d.Organization, d.Grantor, sealTo) + return err + }) + if err != nil { + return Issued{}, err + } + root, err := s.startFenced(ctx, f, workcontext.StartInput{ + TenantID: d.Organization, OrganizationID: d.Organization, OwnerPrincipalID: d.Grantor, OwnerPrincipalKind: "human", + TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scope.Limits{}.Project(through, scopes), + InstallationID: sealTo, TTL: clamp(r.TTL, s.cfg.MaxTTL), ReplayPolicy: workcontext.ReplayIdempotent, + }) + if err != nil { + return Issued{}, err + } + current, err := s.Verify(ctx, root.Token, audience.Principal) + if err != nil { + return Issued{}, err + } + token, signed, err := s.authority.Child(ctx, current, workcontext.ChildInput{ + PrincipalID: am.Principal, PrincipalKind: "service", DelegationID: d.ID, GrantedScopes: scopes, Audience: audience.Principal, + TTL: clamp(r.TTL, s.cfg.MaxTTL), ReplayPolicy: workcontext.ReplayIdempotent, OperationBindingID: d.Organization + "/" + am.Name + "/" + r.Binding, + }) + if err != nil { + return Issued{}, err + } + if again, err := s.Delegation(ctx, id, nil, &actor); err != nil || again.Revoked { + return Issued{}, domain.Refuse("delegation %s is revoked", id) + } + if signed.GetOperationBinding().GetRevision() != am.Generation { + return Issued{}, domain.Refuse("actor binding changed during issuance") + } + return Issued{token, signed}, nil +} diff --git a/libraries/authority-core/go/authority/harness_test.go b/libraries/authority-core/go/authority/harness_test.go index 831be59f..0baf5d31 100644 --- a/libraries/authority-core/go/authority/harness_test.go +++ b/libraries/authority-core/go/authority/harness_test.go @@ -35,6 +35,7 @@ type harness struct { t *testing.T store *memstore.Store svc *authority.Service + cfg authority.Config now time.Time ctx context.Context } @@ -73,8 +74,9 @@ func newHarness(t *testing.T) *harness { n := 0 replay := workcontext.NewMemoryReplayStore() replay.Now = func() time.Time { return h.now } - h.svc, err = authority.New(h.store, authority.Config{Issuer: "https://authority.test", KeyID: "k1", Signer: private, Keys: keys{public}, Replay: replay, - Clock: func() time.Time { return h.now }, NewID: func() string { n++; return fmt.Sprintf("id-%d", n) }}) + h.cfg = authority.Config{Issuer: "https://authority.test", KeyID: "k1", Signer: private, Keys: keys{public}, Replay: replay, + Clock: func() time.Time { return h.now }, NewID: func() string { n++; return fmt.Sprintf("id-%d", n) }} + h.svc, err = authority.New(h.store, h.cfg) if err != nil { t.Fatal(err) } @@ -167,6 +169,44 @@ func (h *harness) revoke(target, role, person, team string) { } } +func (h *harness) root(person, target string, scopes ...*basev0.WorkScopeV1) (authority.Issued, error) { + return h.svc.IssueForPerson(h.ctx, h.person(person), authority.IssueRequest{Audience: target, Scopes: scopes}) +} + +// through is a person at target, then target's hop to the store (or binding). +func (h *harness) through(person, target, binding string, scopes ...*basev0.WorkScopeV1) (authority.Issued, error) { + r, err := h.root(person, target) + if err != nil { + return r, err + } + return h.svc.IssueChild(h.ctx, r.Token, authority.Workload{Module: target}, authority.IssueRequest{Binding: binding, Scopes: scopes}) +} + +func (h *harness) hop(parent authority.Issued, binding string, scopes ...*basev0.WorkScopeV1) (authority.Issued, error) { + return h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: binding, Scopes: scopes}) +} + +func (h *harness) agent(target, binding string, scopes ...*basev0.WorkScopeV1) (authority.Issued, error) { + audience := target + if binding != "" { + audience = map[string]string{"store": "store", "legacy": "legacy-store"}[binding] + } + return h.svc.IssueForInstallation(h.ctx, authority.Workload{Module: target}, h.inst(acme, target).ID, authority.IssueRequest{Audience: audience, Binding: binding, Scopes: scopes}) +} + +func (h *harness) check(i authority.Issued) error { + _, _, err := h.svc.Check(h.ctx, i.Token, i.Context.GetAudience()) + return err +} + +func (h *harness) must(i authority.Issued, err error) authority.Issued { + h.t.Helper() + if err != nil { + h.t.Fatal(err) + } + return i +} + // refused requires a refusal naming reason. func refused(t *testing.T, err error, reason string) { t.Helper() @@ -182,4 +222,28 @@ func code(t *testing.T, err error, want domain.Code) { } } +// effective is a capability's effective scopes. +func effective(i authority.Issued) []*basev0.WorkScopeV1 { + if chain := i.Context.GetActorChain(); len(chain) > 0 { + return chain[len(chain)-1].GetGrantedScopes() + } + return i.Context.GetAuthorityScopes() +} + +// exactly requires the effective scopes to be exactly one row of k with +// actions, owner and ids. +func exactly(t *testing.T, i authority.Issued, k, owner string, actions []string, ids ...string) { + t.Helper() + var rows []*basev0.WorkScopeV1 + for _, s := range effective(i) { + if len(s.GetActions()) > 0 { + rows = append(rows, s) + } + } + if len(rows) != 1 || rows[0].GetResourceKind() != k || rows[0].GetResourceOwnerId() != owner || + strings.Join(rows[0].GetActions(), ",") != strings.Join(actions, ",") || strings.Join(rows[0].GetResourceIds(), ",") != strings.Join(ids, ",") { + t.Fatalf("effective %v, want %s %v owned by %q ids %v", rows, k, actions, owner, ids) + } +} + var _ = errors.New diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go new file mode 100644 index 00000000..0e37974c --- /dev/null +++ b/libraries/authority-core/go/authority/issue.go @@ -0,0 +1,631 @@ +package authority + +import ( + "context" + "errors" + "slices" + "time" + + "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" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" +) + +// IssueRequest is what every owner shape asks: an audience (or the binding +// whose audience it is), the binding exercised and its operation, the +// organization of a workload's work, the scopes asked (none: everything held) +// and a lifetime. +type IssueRequest struct { + Audience, Binding, Operation, Organization string + Scopes Scopes + TTL time.Duration +} + +// Issued is a signed capability and its claims. +type Issued struct { + Token string + Context *basev0.WorkContextV1 +} + +// 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) { + if audience.Kind == domain.KindSolution { + inst, err := installationOf(tx, p.Organization, audience.Name) + if errors.Is(err, domain.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + grants, err := tx.RoleGrants(inst.ID) + if err != nil { + return nil, err + } + role, err := decide.RoleScopes(audience, inst, decide.HeldRoles(p, inst, grants)) + if err != nil { + return nil, err + } + return scope.Intersect(role, audience.ResourceKinds), nil + } + roles, err := orgRoles(tx, p) + if err != nil { + return nil, err + } + return decide.OrgScopes(roles, audience), nil +} + +// solutionCeiling intersects the current roles of each solution the verified +// path names; bounded distinguishes no solution from a solution granting nothing. +func solutionCeiling(tx Tx, p domain.Person, principals []string) (ceiling Scopes, bounded bool, err error) { + seen := map[string]bool{} + for _, principal := range principals { + if seen[principal] { + continue + } + seen[principal] = true + m, err := tx.Module(principal) + if err != nil { + return nil, false, notFound(err) + } + if m.Kind != domain.KindSolution { + continue + } + inst, err := installationOf(tx, p.Organization, m.Name) + var role Scopes + if err == nil { + grants, gerr := tx.RoleGrants(inst.ID) + if gerr != nil { + return nil, false, gerr + } + if role, err = decide.RoleScopes(m, inst, decide.HeldRoles(p, inst, grants)); err != nil { + return nil, false, err + } + } else if !errors.Is(err, domain.ErrNotFound) { + return nil, false, err + } + if bounded { + ceiling = scope.Intersect(ceiling, role) + } else { + ceiling = role + } + bounded = true + } + return ceiling, bounded, nil +} + +// 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) { + if ceiling, bounded, err = solutionCeiling(tx, p, principals); err != nil { + return + } + if !bounded { + h, err = held(tx, p, audience) + return + } + h = scope.Intersect(ceiling, audience.ResourceKinds) + if audience.Kind == domain.KindSolution { + var target Scopes + if target, err = held(tx, p, audience); err != nil { + return + } + h = scope.Intersect(h, target) + } + return +} + +// solutionPrincipals are the current actor and the services of a verified chain. +func solutionPrincipals(wc *basev0.WorkContextV1, current string) []string { + out := []string{current} + for _, actor := range wc.GetActorChain() { + if actor.GetPrincipalKind() == "service" { + out = append(out, actor.GetPrincipalId()) + } + } + return out +} + +func (s *Service) audienceOf(tx Tx, r *IssueRequest, actor string) error { + if r.Audience != "" || r.Binding == "" || actor == "" { + if r.Audience == "" { + return domain.Refuse("an issue names its audience, or the binding whose audience it is") + } + return nil + } + m, err := tx.ModuleNamed(actor) + if err != nil { + return notFound(err) + } + b, ok := m.Bindings[r.Binding] + if !ok { + return domain.Missing("operation binding %q is not one %s declares", r.Binding, m.Name) + } + r.Audience = b.Audience + return nil +} + +// startFenced mints and refuses what was minted unless it carries the +// revision, seal and epoch the decision read: a revoke, a leave or an +// uninstall committed in between must not turn old scopes into fresh +// authority. +func (s *Service) startFenced(ctx context.Context, f fence, in workcontext.StartInput) (Issued, error) { + token, signed, err := s.authority.Start(ctx, in) + if err != nil { + return Issued{}, err + } + if err := f.holds(signed); err != nil { + return Issued{}, err + } + return Issued{token, signed}, nil +} + +// IssueForPerson: owner = actor = the person; scopes = asked within what they +// hold at the audience (a solution: their granted roles on its principal's +// data); sealed to the audience's installation at their organization. +func (s *Service) IssueForPerson(ctx context.Context, who Person, r IssueRequest) (Issued, error) { + var f fence + var audience domain.Module + var heldHere, scopes Scopes + var inst domain.Installation + err := s.store.Read(ctx, func(tx Tx) error { + var err error + if err = s.audienceOf(tx, &r, ""); err != nil { + return err + } + if audience, err = tx.Module(r.Audience); err != nil { + return notFound(err) + } + p, err := tx.Person(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) + } + inst, err = installationOf(tx, p.Organization, audience.Name) + if err != nil || inst.Status != domain.Active { + return domain.NotNow("audience %q is not installed in this organization, so there is no installation to seal the capability to", audience.Principal) + } + 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 { + return err + } + if audience.Kind == domain.KindSolution && !audience.OwnerAware { + return decide.OwnerAware(audience, decide.Scopes{{ResourceKind: audience.Name, ResourceOwnerId: inst.PrincipalID}}) + } + if audience.Kind == domain.KindSolution && len(heldHere) == 0 { + return domain.Refuse("%s holds no role on %s", p.ID, audience.Name) + } + if r.Binding != "" { + binding, declared := audience.Bindings[r.Binding] + if !declared { + return domain.Missing("operation binding %q is not one %s declares", r.Binding, audience.Name) + } + target, err := tx.Module(binding.Audience) + if err != nil { + return notFound(err) + } + there, _, _, err := throughSolutions(tx, p, target, []string{audience.Principal}) + if err != nil { + return err + } + through := scope.Intersect(there, binding.InvokeScopes) + if len(through) == 0 { + return domain.Refuse("%s holds nothing under operation binding %q", p.ID, r.Binding) + } + heldHere = scope.Normalize(append(heldHere, through...)) + } + if scopes, err = decide.Issue(r.Scopes, heldHere); err != nil { + return err + } + return decide.OwnerAware(audience, scopes) + }) + if err != nil { + return Issued{}, err + } + return s.startFenced(ctx, f, workcontext.StartInput{ + TenantID: who.Organization, OrganizationID: who.Organization, + OwnerPrincipalID: who.ID, OwnerPrincipalKind: "human", TaskID: s.cfg.NewID(), SessionID: who.SessionID, + Audience: audience.Principal, AuthorityScopes: scope.Limits{}.Project(heldHere, scopes), AttributionTeamIDs: sortedTeams(who.Teams), + InstallationID: inst.ID, TTL: clamp(r.TTL, s.cfg.MaxTTL), + }) +} + +// IssueForWorkload: owner = actor = the module; scopes = its binding's +// headless scopes within asked; the binding names the audience. +func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueRequest) (Issued, error) { + var module, audience domain.Module + var through, scopes Scopes + var org, instID string + err := s.store.Read(ctx, func(tx Tx) error { + var err error + if module, err = tx.ModuleNamed(w.Module); err != nil { + return notFound(err) + } + if r.Binding == "" { + return domain.Refuse("a workload issue names the binding it exercises") + } + if err = s.audienceOf(tx, &r, w.Module); err != nil { + return err + } + if audience, err = tx.Module(r.Audience); err != nil { + return notFound(err) + } + org = r.Organization + if org == "" { + var orgs []string + all, err := tx.Installations("") + if err != nil { + return err + } + for _, inst := range all { + if inst.Target == module.Name && inst.Status == domain.Active { + orgs = append(orgs, inst.Organization) + } + } + switch len(orgs) { + case 0: + return domain.Refuse("module %q has no active installation; a workload must name the organization of the work item", module.Name) + case 1: + org = orgs[0] + default: + return domain.Refuse("module %q has ambiguous active installations across organizations; a workload must name the organization of the work item", module.Name) + } + } + binding, ok := module.Bindings[r.Binding] + if !ok { + return workcontext.ErrNoBinding + } + if binding.Audience != audience.Principal { + return domain.Refuse("binding %s is addressed to %s, not %s", r.Binding, binding.Audience, audience.Principal) + } + if through, err = decide.ScopesFor(binding, r.Operation, "headless"); err != nil { + return err + } + if scopes, err = decide.Issue(r.Scopes, through); err != nil { + return err + } + inst, err := installationOf(tx, org, module.Name) + if err != nil { + return domain.NotNow("%s is not installed at %s", module.Name, org) + } + instID = inst.ID + return decide.OwnerAware(audience, scopes) + }) + if err != nil { + return Issued{}, err + } + token, signed, err := s.authority.Start(ctx, workcontext.StartInput{ + TenantID: org, OrganizationID: org, OwnerPrincipalID: module.Principal, OwnerPrincipalKind: "service", + TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scope.Limits{}.Project(through, scopes), + InstallationID: instID, OperationBindingID: org + "/" + module.Name + "/" + r.Binding, TTL: clamp(r.TTL, s.cfg.MaxTTL), + }) + if err != nil { + return Issued{}, err + } + if signed.GetSeal().GetPrincipalEpoch() != module.Generation || signed.GetOperationBinding().GetRevision() != module.Generation { + return Issued{}, domain.Refuse("workload binding changed during issuance") + } + return Issued{token, signed}, nil +} + +// IssueForInstallation: owner = the installation's principal acting for +// itself, with its agent role, confined to its own data; at another audience +// it acts through one of its solution's bindings. Its solution's workload asks. +func (s *Service) IssueForInstallation(ctx context.Context, w Workload, installationID string, r IssueRequest) (Issued, error) { + var inst domain.Installation + var solution, audience domain.Module + var heldHere, scopes Scopes + var f fence + bindingID := "" + err := s.store.Read(ctx, func(tx Tx) error { + var err error + if solution, err = tx.ModuleNamed(w.Module); err != nil { + return notFound(err) + } + inst, err = tx.Installation(installationID) + if errors.Is(err, domain.ErrNotFound) || (err == nil && r.Organization != "" && r.Organization != inst.Organization) { + return decide.ForeignInstallation(installationID, r.Organization) + } + if err != nil { + return err + } + if solution.Kind != domain.KindSolution || solution.Name != inst.Target { + return domain.Refuse("installation issuance requires the installed solution's workload") + } + if inst.Status != domain.Active { + return domain.NotNow("installation %s is %s: %s is not installed", inst.ID, inst.Status, inst.Target) + } + if err = s.audienceOf(tx, &r, w.Module); err != nil { + return err + } + if audience, err = tx.Module(r.Audience); err != nil { + return notFound(err) + } + if f, err = s.src.fenceIn(tx, inst.Organization, inst.PrincipalID, inst.ID); err != nil { + return err + } + // Everything the installation holds for itself is owner-selected. + if err := decide.OwnerAware(audience, decide.Scopes{{ResourceKind: audience.Name, ResourceOwnerId: inst.PrincipalID}}); err != nil { + return err + } + agent, err := decide.RoleScopes(solution, inst, []string{inst.AgentRole}) + if err != nil { + return err + } + heldHere = scope.Intersect(agent, audience.ResourceKinds) + if audience.Principal != solution.Principal { + if r.Binding == "" { + return domain.Refuse("the installation acting for itself reaches %s only through an operation binding its solution declares; it names no binding", audience.Principal) + } + binding, ok := solution.Bindings[r.Binding] + if !ok { + return domain.Missing("operation binding %q is not one %s declares", r.Binding, solution.Name) + } + if binding.Audience != audience.Principal { + return domain.Refuse("binding %q is addressed to %s, not %s", r.Binding, binding.Audience, audience.Principal) + } + through, err := decide.ScopesFor(binding, r.Operation, "invoke") + if err != nil { + return err + } + heldHere = scope.Intersect(heldHere, through) + bindingID = r.Binding + } + if scopes, err = decide.Issue(r.Scopes, heldHere); err != nil { + return err + } + return decide.OwnerAware(audience, scopes) + }) + if err != nil { + return Issued{}, err + } + issued, err := s.startFenced(ctx, f, workcontext.StartInput{ + TenantID: inst.Organization, OrganizationID: inst.Organization, + OwnerPrincipalID: inst.PrincipalID, OwnerPrincipalKind: "agent", OwnerAgentID: inst.Target, + TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scope.Limits{}.Project(heldHere, scopes), + InstallationID: inst.ID, TTL: clamp(r.TTL, s.cfg.MaxTTL), ReplayPolicy: workcontext.ReplayIdempotent, + }) + if err != nil { + return Issued{}, err + } + if bindingID != "" { + // Another audience is reached through the solution's binding, as a hop + // of its workload, so revoking the binding reaches the capability. + current, err := s.Verify(ctx, issued.Token, audience.Principal) + if err != nil { + return Issued{}, err + } + token, signed, err := s.authority.Child(ctx, current, workcontext.ChildInput{ + PrincipalID: solution.Principal, PrincipalKind: "service", DelegationID: current.Context().GetSessionId(), + GrantedScopes: scopes, Audience: audience.Principal, TTL: clamp(r.TTL, s.cfg.MaxTTL), ReplayPolicy: workcontext.ReplayIdempotent, + OperationBindingID: inst.Organization + "/" + solution.Name + "/" + bindingID, + }) + if err != nil { + return Issued{}, err + } + issued = Issued{token, signed} + } + if epoch, err := s.src.PrincipalEpoch(ctx, solution.Principal); err != nil || epoch != solution.Generation { + return Issued{}, domain.Refuse("installation workload changed during issuance") + } + return issued, nil +} + +// IssueChild is one more hop, inherited from the owner (rule A): the owner's +// current grants at the audience within the actor's binding, within every +// narrowing a parent made. A person's grants reach a solution's path only as +// its roles on its principal's data. The child is sealed to the actor's +// installation (core binds a binding to its module's installation), so a hop +// by a solution is sealed to that solution's installation: revoking a role +// there, or uninstalling it, ends it. +func (s *Service) IssueChild(ctx context.Context, parent string, actor Workload, r IssueRequest) (Issued, error) { + var am, audience domain.Module + var binding domain.Binding + var through, scopes, authorityScopes Scopes + var claims *basev0.WorkContextV1 + var verified *workcontext.Verified + var sealTo string + err := s.store.Read(ctx, func(tx Tx) error { + var err error + if am, err = tx.ModuleNamed(actor.Module); err != nil { + return notFound(err) + } + if r.Binding == "" { + return domain.Refuse("a module's hop names the binding it exercises") + } + if err = s.audienceOf(tx, &r, actor.Module); err != nil { + return err + } + var ok bool + if binding, ok = am.Bindings[r.Binding]; !ok { + return domain.Missing("operation binding %q is not one %s declares", r.Binding, am.Name) + } + if audience, err = tx.Module(r.Audience); err != nil { + return notFound(err) + } + if binding.Audience != audience.Principal { + return domain.Refuse("binding %q is addressed to %s, not %s", r.Binding, binding.Audience, audience.Principal) + } + return nil + }) + if err != nil { + return Issued{}, err + } + decoded, err := workcontext.Decode(parent) + if err != nil { + return Issued{}, err + } + if verified, err = s.Verify(ctx, parent, decoded.Context().GetAudience()); err != nil { + return Issued{}, err + } + claims = verified.Context() + if claims.GetAudience() != am.Principal { + return Issued{}, domain.Refuse("%s hops only on a token addressed to it; this one is addressed to %s", am.Name, claims.GetAudience()) + } + var f fence + err = s.store.Read(ctx, func(tx Tx) error { + tenant := claims.GetTenantId() + var heldHere, sourceCeiling Scopes + bounded := false + switch claims.GetOwnerPrincipalKind() { + case "human": + p, err := tx.Person(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 { + return err + } + case "agent": + inst, err := tx.InstallationByPrincipal(claims.GetOwnerPrincipalId()) + if err != nil || inst.Status != domain.Active { + return domain.Refuse("the installation %s acts for is not installed", claims.GetOwnerPrincipalId()) + } + solution, err := tx.ModuleNamed(inst.Target) + if err != nil { + return notFound(err) + } + agent, err := decide.RoleScopes(solution, inst, []string{inst.AgentRole}) + if err != nil { + return err + } + heldHere = scope.Intersect(agent, audience.ResourceKinds) + default: + heldHere = verified.EffectiveScopes() + } + if (bounded || claims.GetOwnerPrincipalKind() == "agent") && !audience.OwnerAware { + return decide.OwnerAware(audience, decide.Scopes{{ResourceKind: audience.Name, ResourceOwnerId: claims.GetOwnerPrincipalId()}}) + } + var err error + if through, err = decide.ScopesFor(binding, r.Operation, "invoke"); err != nil { + return err + } + ceiling := scope.Intersect(heldHere, through) + if bounded { + ceiling = scope.Intersect(ceiling, sourceCeiling) + } + if ceiling, err = delegationCeiling(tx, claims, ceiling); err != nil { + return err + } + limits := scope.ParentLimits(claims) + ceiling = limits.Constrain(ceiling) + if len(ceiling) == 0 { + return domain.Refuse("%s holds nothing at %s through binding %q", claims.GetOwnerPrincipalId(), audience.Name, r.Binding) + } + scopes = ceiling + if len(r.Scopes) > 0 { + if !scope.Attenuates(r.Scopes, ceiling) { + return decide.Child(r.Binding, bounded) + } + scopes = scope.Normalize(r.Scopes) + } + if err := decide.OwnerAware(audience, scopes); err != nil { + return err + } + authorityScopes = limits.Project(through, scopes) + inst, err := installationOf(tx, tenant, am.Name) + if err != nil { + return domain.NotNow("%s is not installed at %s", am.Name, tenant) + } + sealTo = inst.ID + f, err = s.src.fenceIn(tx, tenant, claims.GetOwnerPrincipalId(), sealTo) + return err + }) + if err != nil { + return Issued{}, err + } + return s.derive(ctx, verified, am, audience, r, scopes, authorityScopes, sealTo, f) +} + +// derive rebuilds the chain for the new audience, the parent's actors and +// then this one, and rechecks the parent once the child is signed. +func (s *Service) derive(ctx context.Context, verified *workcontext.Verified, am, audience domain.Module, r IssueRequest, scopes, authorityScopes Scopes, sealTo string, f fence) (Issued, error) { + claims := verified.Context() + ttl := clamp(r.TTL, s.cfg.MaxTTL) + if remaining := time.Unix(claims.GetExpiresAtUnix(), 0).Sub(s.cfg.Clock()); remaining < ttl { + ttl = remaining + } + if ttl <= 0 { + return Issued{}, domain.Refuse("the parent has expired") + } + tenant := claims.GetTenantId() + hops := slices.Clone(claims.GetActorChain()) + parentSession, rootSession := claims.GetSessionId(), "" + if len(hops) == 0 { + rootSession = parentSession + } + token, _, err := s.authority.Start(ctx, workcontext.StartInput{ + TenantID: tenant, OrganizationID: claims.GetOrganizationId(), + OwnerPrincipalID: claims.GetOwnerPrincipalId(), OwnerPrincipalKind: claims.GetOwnerPrincipalKind(), OwnerAgentID: claims.GetOwnerAgentId(), + TaskID: claims.GetTaskId(), Audience: audience.Principal, AuthorityScopes: authorityScopes, AttributionTeamIDs: claims.GetAttributionTeamIds(), + InstallationID: sealTo, SessionID: rootSession, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, + }) + if err != nil { + return Issued{}, err + } + for n := 0; n <= len(hops); n++ { + current, err := s.Verify(ctx, token, audience.Principal) + if err != nil { + return Issued{}, err + } + in := workcontext.ChildInput{GrantedScopes: scopes, Audience: audience.Principal, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent} + if n < len(hops) { + in.GrantedScopes = authorityScopes + in.PrincipalID, in.PrincipalKind, in.DelegationID = hops[n].GetPrincipalId(), hops[n].GetPrincipalKind(), hops[n].GetDelegationId() + if n == len(hops)-1 { + in.SessionID = parentSession + } + } else { + in.PrincipalID, in.PrincipalKind, in.DelegationID = am.Principal, "service", claims.GetSessionId() + in.OperationBindingID = tenant + "/" + am.Name + "/" + r.Binding + } + var signed *basev0.WorkContextV1 + if token, signed, err = s.authority.Child(ctx, current, in); err != nil { + return Issued{}, err + } + if n == len(hops) { + if signed.GetOperationBinding().GetRevision() != am.Generation { + return Issued{}, domain.Refuse("actor binding changed during issuance") + } + if err := f.holds(signed); err != nil { + return Issued{}, err + } + v, err := s.verifier(ctx, claims.GetAudience()) + if err != nil { + return Issued{}, err + } + if err := v.Recheck(ctx, verified); err != nil { + return Issued{}, err + } + return Issued{token, signed}, nil + } + } + return Issued{}, domain.Refuse("no hop was derived") +} + +// delegationCeiling reads stored delegations named on the parent's hops. +func delegationCeiling(tx Tx, parent *basev0.WorkContextV1, ceiling Scopes) (Scopes, error) { + for _, hop := range parent.GetActorChain() { + d, err := tx.Delegation(hop.GetDelegationId()) + if errors.Is(err, domain.ErrNotFound) { + continue + } + if err != nil { + return nil, err + } + m, err := tx.Module(hop.GetPrincipalId()) + if err != nil { + return nil, notFound(err) + } + if d.Revoked || d.Grantor != parent.GetOwnerPrincipalId() || d.Organization != parent.GetOrganizationId() || d.Module != m.Name { + return nil, domain.Refuse("the explicit delegation does not authorize this actor chain") + } + ceiling = scope.Intersect(ceiling, d.Scopes) + } + return ceiling, nil +} diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go index ffdf0368..24f80731 100644 --- a/libraries/authority-core/go/authority/lifecycle.go +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -21,11 +21,25 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e } var ids []string err := s.store.Write(ctx, func(tx Tx) error { + ids = nil // a retried transaction starts over previous, err := tx.ModuleNamed(m.Name) exists := err == nil if err != nil && !errors.Is(err, domain.ErrNotFound) { return err } + if !exists { + // A module withdrawn before keeps its principal and its last + // generation: a redelivery never renames it or rewinds it. + tomb, err := tx.Tombstone(m.Name) + switch { + case err == nil && tomb.Principal != m.Principal: + return domain.Refuse("%s is principal %q for life; a delivery cannot rename it %q", m.Name, tomb.Principal, m.Principal) + case err == nil && tomb.Generation >= m.Generation: + return domain.NotNow("%s was withdrawn at %d, got %d", m.Name, tomb.Generation, m.Generation) + case err != nil && !errors.Is(err, domain.ErrNotFound): + return err + } + } if exists && !previous.StandIn && previous.Generation >= m.Generation { return domain.NotNow("%s is at %d, got %d", m.Name, previous.Generation, m.Generation) } @@ -80,10 +94,21 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e } for _, org := range orgs { id := s.cfg.InstallationID(org.ID, m.Name) - if _, err := tx.Installation(id); errors.Is(err, domain.ErrNotFound) { + inst, err := tx.Installation(id) + switch { + case errors.Is(err, domain.ErrNotFound): if err := tx.PutInstallation(domain.Installation{ID: id, Organization: org.ID, Target: m.Name, Status: domain.Active, Revision: 1}); err != nil { return err } + case err != nil: + return err + case inst.Status == domain.Revoked: + // A module withdrawn and delivered again is installed again, + // at a revision above every capability sealed before. + inst.Status, inst.Revision = domain.Active, inst.Revision+1 + if err := tx.PutInstallation(inst); err != nil { + return err + } } ids = append(ids, id) } @@ -123,6 +148,13 @@ func (s *Service) WithdrawModule(ctx context.Context, name string) error { } } } + m, err := tx.ModuleNamed(name) + if err != nil { + return notFound(err) + } + if err := tx.PutTombstone(domain.Tombstone{Name: m.Name, Principal: m.Principal, Generation: m.Generation}); err != nil { + return err + } return tx.DeleteModule(name) }) } @@ -161,11 +193,31 @@ func (s *Service) Install(ctx context.Context, caller *Person, org, target, agen } id := s.cfg.InstallationID(org, target) existing, err := tx.Installation(id) + if err == nil && existing.Status == domain.Pending { + if existing.AgentRole != agentRole || existing.InstalledBy != installedBy { + return domain.NotNow("%s is pending at %s as %s for %s; only that install resumes it", target, org, existing.AgentRole, existing.InstalledBy) + } + // The same install asked again resumes its pending attempt: its + // step converges idempotently, and its revision stays the one the + // first attempt took, so a revoke or a reinstall since is told apart. + inst = existing + return nil + } if err == nil && existing.Status != domain.Revoked { return domain.Refuse("%s is already installed at %s", target, org) } + principal := s.cfg.PrincipalID(org, target) + if _, err := tx.Module(principal); err == nil { + return domain.Refuse("the principal %q of %s at %s names a module", principal, target, org) + } + if _, err := tx.Person(principal); err == nil { + return domain.Refuse("the principal %q of %s at %s names a person", principal, target, org) + } + if other, err := tx.InstallationByPrincipal(principal); err == nil && other.ID != id { + return domain.Refuse("the principal %q of %s at %s is another installation's", principal, target, org) + } inst = domain.Installation{ID: id, Organization: org, Target: target, Status: domain.Pending, Revision: existing.Revision + 1, - AgentRole: agentRole, PrincipalID: s.cfg.PrincipalID(org, target), InstalledBy: installedBy} + AgentRole: agentRole, PrincipalID: principal, InstalledBy: installedBy} if err := tx.DeleteRoleGrants(id); err != nil { return err } @@ -204,6 +256,9 @@ func (s *Service) Install(ctx context.Context, caller *Person, org, target, agen // absent one. func (s *Service) Uninstall(ctx context.Context, caller *Person, organization, installationID string) error { return s.store.Write(ctx, func(tx Tx) error { + if caller != nil && callerOrganization(tx, *caller) != organization { + return decide.ForeignInstallation(installationID, organization) + } inst, err := tx.Installation(installationID) if errors.Is(err, domain.ErrNotFound) || (err == nil && inst.Organization != organization) { return decide.ForeignInstallation(installationID, organization) diff --git a/libraries/authority-core/go/authority/principal_test.go b/libraries/authority-core/go/authority/principal_test.go index e0fa53f7..fb112de8 100644 --- a/libraries/authority-core/go/authority/principal_test.go +++ b/libraries/authority-core/go/authority/principal_test.go @@ -6,6 +6,7 @@ import ( "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" ) var read, write, create, admin = []string{"read"}, []string{"write"}, []string{"create"}, []string{"admin"} @@ -24,6 +25,158 @@ func TestThePrincipalIsStableDistinctAndPerOrganization(t *testing.T) { } } +func TestNobodyHoldsARoleUntilGranted(t *testing.T) { + h := newHarness(t) + for _, person := range []string{"maya", "alex"} { + _, err := h.root(person, "a") + refused(t, err, "holds no role") + } +} + +func TestAViewerReadsOnlyThePrincipalsData(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + u, v := h.principal(acme, "a"), h.principal(acme, "b") + exactly(t, h.must(h.root("maya", "a")), "a.note", u, read) + exactly(t, h.must(h.through("maya", "a", "store")), kind, u, read) + exactly(t, h.must(h.through("maya", "a", "store", owned(kind, u, read, "u-doc"))), kind, u, read, "u-doc") + for name, ask := range map[string]*basev0.WorkScopeV1{ + "another principal": owned(kind, v, read), + "another organization's": owned(kind, h.principal(globex, "a"), read), + "the whole kind": sc(kind, "read"), + "an id alone": {ResourceKind: kind, Actions: read, ResourceIds: []string{"store-x"}}, + "a person as owner": owned(kind, "maya", read), + "the installation as owner": owned(kind, h.inst(acme, "a").ID, read), + "the organization as owner": owned(kind, acme, read), + "an action the role lacks": owned(kind, u, write), + } { + t.Run(name, func(t *testing.T) { + _, err := h.through("maya", "a", "store", ask) + refused(t, err, "widens") + }) + } + grants, err := h.svc.ListResources(h.ctx, h.must(h.through("maya", "a", "store")).Token, "store", kind, "read") + if err != nil || len(grants) != 1 || !grants[0].All || grants[0].Owner != u { + t.Fatalf("listed %+v %v", grants, err) + } +} + +func TestTheAgentIsThePrincipalConfinedToItsData(t *testing.T) { + h := newHarness(t) + u := h.principal(acme, "a") + agent := h.must(h.agent("a", "store")) + if agent.Context.GetOwnerPrincipalId() != u || agent.Context.GetOwnerPrincipalKind() != "agent" { + t.Fatalf("the agent is %s (%s)", agent.Context.GetOwnerPrincipalId(), agent.Context.GetOwnerPrincipalKind()) + } + exactly(t, agent, kind, u, []string{"create", "read", "write"}) + _, err := h.agent("a", "store", owned(kind, u, admin)) + refused(t, err, "beyond") + _, err = h.svc.IssueForInstallation(h.ctx, authority.Workload{Module: "a"}, h.inst(acme, "a").ID, authority.IssueRequest{Audience: "store"}) + refused(t, err, "binding") + for _, ask := range []*basev0.WorkScopeV1{sc(kind, "read"), owned(kind, h.principal(acme, "b"), read), {ResourceKind: kind, Actions: read, ResourceIds: []string{"x"}}} { + _, err := h.agent("a", "store", ask) + refused(t, err, "widens") + } +} + +func TestASolutionCapabilityIsConfined(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "rob") + h.grant("a", "viewer", "ada") + exactly(t, h.must(h.through("rob", "a", "store")), kind, h.principal(acme, "a"), read) + _, err := h.through("rob", "a", "store", &basev0.WorkScopeV1{ResourceKind: kind, Actions: read, ResourceIds: []string{"store-x"}}) + refused(t, err, "confined") + _, err = h.through("ada", "a", "store", &basev0.WorkScopeV1{ResourceKind: kind, Actions: admin, ResourceIds: []string{"store-x"}}) + refused(t, err, "confined") +} + +func TestOwnerSelectedScopesGoOnlyToOwnerAwareAudiences(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + h.grant("c", "viewer", "maya") + _, err := h.through("maya", "a", "legacy") + refused(t, err, "owner-aware") + _, err = h.agent("a", "legacy") + refused(t, err, "owner-aware") + _, err = h.root("maya", "c") + refused(t, err, "owner-aware") + _, err = h.hop(h.must(h.through("maya", "a", "store")), "legacy") + refused(t, err, "owner-aware") +} + +func TestWithdrawingOwnerAwareRefusesEarlierCapabilitiesForGood(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + before := h.must(h.through("maya", "a", "store")) + store := func(gen uint64, aware bool) { + if _, err := h.svc.ApplyModule(h.ctx, domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: gen, OwnerAware: aware, + ResourceKinds: []*basev0.WorkScopeV1{sc(kind, "admin", "create", "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")}}, + "legacy": {ID: "legacy", Audience: "legacy-store", InvokeScopes: []*basev0.WorkScopeV1{sc(legacyKind, "read")}}}}); err != nil { + t.Fatal(err) + } + } + store(2, false) + refused(t, h.check(before), "owner-aware") + store(3, true) + refused(t, h.check(before), "owner-aware") + if err := h.check(h.must(h.through("maya", "a", "store"))); err != nil { + t.Fatalf("a fresh capability: %v", err) + } +} + +func TestARoleWritesByItsScopesNotItsName(t *testing.T) { + h := newHarness(t) + h.grant("a", "author", "remi") + h.grant("a", "editor", "dev") + u := h.principal(acme, "a") + exactly(t, h.must(h.through("remi", "a", "store", owned(kind, u, write))), kind, u, write) + _, err := h.through("dev", "a", "store", owned(kind, u, write)) + refused(t, err, "widens") + created := h.must(h.through("remi", "a", "store", owned(kind, u, create))) + if created.Context.GetOwnerPrincipalId() != "remi" { + t.Fatal("the author is the person") + } +} + +func TestAChildNeverWidens(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + h.grant("a", "author", "remi") + u, v := h.principal(acme, "a"), h.principal(acme, "b") + narrowed := h.must(h.through("maya", "a", "store", owned(kind, u, read))) + exactly(t, h.must(h.hop(narrowed, "self")), kind, u, read) + readOnly := h.must(h.through("remi", "a", "store", owned(kind, u, read))) + oneDoc := h.must(h.through("maya", "a", "store", owned(kind, u, read, "u-doc"))) + exactly(t, h.must(h.hop(oneDoc, "self")), kind, u, read, "u-doc") + for name, c := range map[string]struct { + parent authority.Issued + ask *basev0.WorkScopeV1 + }{ + "another owner": {narrowed, owned(kind, v, read)}, "the whole kind": {narrowed, sc(kind, "read")}, + "a dropped action": {readOnly, owned(kind, u, write)}, "the narrowed ids": {oneDoc, owned(kind, u, read)}, + } { + t.Run(name, func(t *testing.T) { + _, err := h.hop(c.parent, "self", c.ask) + refused(t, err, "widens") + }) + } +} + +func TestOtherInstallationsAndOrganizationsNeverReach(t *testing.T) { + h := newHarness(t) + h.grant("b", "viewer", "dev") + _, err := h.root("dev", "a") + refused(t, err, "holds no role") + exactly(t, h.must(h.through("dev", "b", "store")), kind, h.principal(acme, "b"), read) + _, err = h.through("dev", "b", "store", owned(kind, h.principal(acme, "a"), read)) + refused(t, err, "widens") + if _, err := h.svc.GrantRole(h.ctx, h.person("zoe"), authority.RoleRequest{Organization: globex, InstallationID: h.inst(globex, "a").ID, Role: "viewer", PersonID: "omar"}); err != nil { + t.Fatal(err) + } + exactly(t, h.must(h.through("omar", "a", "store")), kind, h.principal(globex, "a"), read) +} + func TestWhoMayGrantAndEveryMalformedGrant(t *testing.T) { h := newHarness(t) h.grant("a", "viewer", "maya") @@ -45,7 +198,7 @@ func TestWhoMayGrantAndEveryMalformedGrant(t *testing.T) { reason string }{ "a viewer granting itself": {h.person("maya"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: domain.InstallerRole, PersonID: "maya"}, domain.Denied, "may not grant"}, - "another organization's administrator": {h.person("zoe"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "viewer", PersonID: "dev"}, domain.Denied, "may not grant"}, + "another organization's administrator": {h.person("zoe"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "viewer", PersonID: "dev"}, domain.Denied, "belongs to another organization"}, "A's installer on B": {ines, authority.RoleRequest{Organization: acme, InstallationID: instB, Role: "author", PersonID: "ines"}, domain.Denied, "may not grant"}, "another organization named": {h.person("alex"), authority.RoleRequest{Organization: globex, InstallationID: instA, Role: "viewer", PersonID: "dev"}, domain.Denied, "organization"}, "another organization's installation": {h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: elsewhere, Role: "viewer", PersonID: "dev"}, domain.Denied, "organization"}, diff --git a/libraries/authority-core/go/authority/r124_test.go b/libraries/authority-core/go/authority/r124_test.go index 1010a6a5..5012ba55 100644 --- a/libraries/authority-core/go/authority/r124_test.go +++ b/libraries/authority-core/go/authority/r124_test.go @@ -7,6 +7,18 @@ import ( "github.com/codefly-dev/authority/libraries/authority-core/go/domain" ) +// A grant stays revocable after its person left the organization, and the +// revoke ends their access for good if they return. +func TestAGrantWhosePersonLeftIsStillRevoked(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + h.store.DeletePerson("maya") + h.revoke("a", "viewer", "maya", "") + h.store.PutPerson(domain.Person{ID: "maya", Organization: acme}) + _, err := h.root("maya", "a") + refused(t, err, "holds no role") +} + // Another organization's installation and an installation nobody has answer // a role change with the same words. func TestAForeignAndAnAbsentInstallationAnswerAlike(t *testing.T) { @@ -22,3 +34,36 @@ func TestAForeignAndAnAbsentInstallationAnswerAlike(t *testing.T) { } } +// A grant stays revocable on an uninstalled installation, whose reinstall +// reuses its id, and when its role is no longer declared, which a later +// generation may declare again: neither door revives it. +func TestARevokeOutlivesUninstallAndARetiredRole(t *testing.T) { + h := newHarness(t) + id := h.inst(acme, "a").ID + h.grant("a", "viewer", "maya") + if err := h.svc.Uninstall(h.ctx, nil, acme, id); err != nil { + t.Fatal(err) + } + h.revoke("a", "viewer", "maya", "") + if again := h.install(acme, "a"); again.ID != id { + t.Fatalf("the reinstall is %s, not %s", again.ID, id) + } + _, err := h.root("maya", "a") + refused(t, err, "holds no role") + + h.grant("a", "viewer", "maya") + retired := solution("a", true) + retired.Generation = 2 + delete(retired.Roles, "viewer") + if _, err := h.svc.ApplyModule(h.ctx, retired); err != nil { + t.Fatal(err) + } + h.revoke("a", "viewer", "maya", "") + back := solution("a", true) + back.Generation = 3 + if _, err := h.svc.ApplyModule(h.ctx, back); err != nil { + t.Fatal(err) + } + _, err = h.root("maya", "a") + refused(t, err, "holds no role") +} diff --git a/libraries/authority-core/go/authority/r143_test.go b/libraries/authority-core/go/authority/r143_test.go new file mode 100644 index 00000000..949a9f37 --- /dev/null +++ b/libraries/authority-core/go/authority/r143_test.go @@ -0,0 +1,249 @@ +package authority_test + +import ( + "context" + "errors" + "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" + "github.com/codefly-dev/core/workcontext" +) + +// M-a: a caller of another organization learns nothing of the one it names: +// an installation it has and one it has not answer alike, in Uninstall and +// in every role call, before anything is read. +func TestAnOutsiderCannotTellInstalledFromAbsent(t *testing.T) { + h := newHarness(t) + zoe := h.person("zoe") + present, absent := h.inst(acme, "a").ID, authority.StableInstallationID(acme, "never-installed") + answer := func(err error, id string) string { + t.Helper() + code(t, err, domain.Denied) + want := "installation " + id + " belongs to another organization than \"acme\"" + if err.Error() != want { + t.Fatalf("got %q, want %q", err, want) + } + return want + } + answer(h.svc.Uninstall(h.ctx, &zoe, acme, present), present) + answer(h.svc.Uninstall(h.ctx, &zoe, acme, absent), absent) + for _, id := range []string{present, absent} { + _, err := h.svc.ListRoleGrants(h.ctx, zoe, acme, id) + answer(err, id) + _, err = h.svc.GrantRole(h.ctx, zoe, authority.RoleRequest{Organization: acme, InstallationID: id, Role: "viewer", PersonID: "dev"}) + answer(err, id) + _, err = h.svc.RevokeRole(h.ctx, zoe, authority.RoleRequest{Organization: acme, InstallationID: id, Role: "viewer", PersonID: "dev"}) + answer(err, id) + } +} + +// M-b: a module withdrawn and delivered again is installed again at every +// organization, at a revision above the one it was revoked at. +func TestAWithdrawnModuleDeliveredAgainIsInstalledAgain(t *testing.T) { + h := newHarness(t) + id := h.inst(acme, "store").ID + before, err := h.svc.Seals().Seal(h.ctx, "maya", id) + if err != nil { + t.Fatal(err) + } + if err := h.svc.WithdrawModule(h.ctx, "store"); err != nil { + t.Fatal(err) + } + store := domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 2, OwnerAware: true, + ResourceKinds: []*basev0.WorkScopeV1{sc(kind, "admin", "create", "read", "write")}} + if _, err := h.svc.ApplyModule(h.ctx, store); err != nil { + t.Fatal(err) + } + after, err := h.svc.Seals().Seal(h.ctx, "maya", id) + if err != nil { + t.Fatalf("the redelivered module is unreachable: %v", err) + } + if after.InstallationRevision <= before.InstallationRevision { + t.Fatalf("the redelivered installation is at %d, was %d", after.InstallationRevision, before.InstallationRevision) + } +} + +// m-a: across withdrawal and redelivery, a module keeps its principal, its +// generation never rewinds, and the seal never goes back. +func TestAWithdrawnModuleKeepsItsPrincipalAndGeneration(t *testing.T) { + h := newHarness(t) + gen := uint64(1) + apply := func(aware bool) { + gen++ + m := solution("a", aware) + m.Generation = gen + if _, err := h.svc.ApplyModule(h.ctx, m); err != nil { + t.Fatal(err) + } + } + apply(false) + apply(true) + apply(false) + before := h.seal("maya", "a") + if err := h.svc.WithdrawModule(h.ctx, "a"); err != nil { + t.Fatal(err) + } + renamed := solution("a", true) + renamed.Principal, renamed.Generation = "a-renamed", gen+1 + _, err := h.svc.ApplyModule(h.ctx, renamed) + code(t, err, domain.Denied) + rewound := solution("a", true) + rewound.Generation = 1 + _, err = h.svc.ApplyModule(h.ctx, rewound) + code(t, err, domain.Precondition) + again := solution("a", true) + again.Generation = gen + 1 + if _, err := h.svc.ApplyModule(h.ctx, again); err != nil { + t.Fatal(err) + } + h.install(acme, "a") + if after := h.seal("maya", "a"); after <= before { + t.Fatalf("the seal went from %d to %d", before, after) + } +} + +// m-b: an operation binding revoked by an uninstall stays revoked: the +// reinstalled installation's binding is another incarnation. +func TestARevokedBindingIsNeverRevived(t *testing.T) { + h := newHarness(t) + id := acme + "/a/store" + before, err := h.svc.Seals().OperationBinding(h.ctx, id) + if err != nil { + t.Fatal(err) + } + if err := h.svc.Uninstall(h.ctx, nil, acme, h.inst(acme, "a").ID); err != nil { + t.Fatal(err) + } + h.install(acme, "a") + after, err := h.svc.Seals().OperationBinding(h.ctx, id) + if err != nil { + t.Fatal(err) + } + if after.Revoked || after.Incarnation <= before.Incarnation { + t.Fatalf("binding before %+v, after reinstall %+v", before, after) + } +} + +// m-c: a transaction that panics leaves nothing behind. +func TestAPanickingWriteLeavesNothing(t *testing.T) { + store := memstore.New() + store.PutOrganization(domain.Organization{ID: acme}) + func() { + defer func() { _ = recover() }() + _ = store.Write(context.Background(), func(tx authority.Tx) error { + if err := tx.BumpAuthorizationRevision(acme); err != nil { + return err + } + panic("mid-transaction") + }) + }() + _ = store.Read(context.Background(), func(tx authority.Tx) error { + if rev, _ := tx.AuthorizationRevision(acme); rev != 1 { + t.Fatalf("the panicking write's revision survived: %d", rev) + } + return nil + }) +} + +// retrying meets one serialization conflict per write and retries fn, as the +// Store contract lets an adapter do. +type retrying struct{ *memstore.Store } + +func (r retrying) Write(ctx context.Context, fn func(authority.Tx) error) error { + conflicted := false + for { + err := r.Store.Write(ctx, func(tx authority.Tx) error { + if err := fn(tx); err != nil { + return err + } + if !conflicted { + conflicted = true + return authority.ErrConflict + } + return nil + }) + if !errors.Is(err, authority.ErrConflict) { + return err + } + } +} + +// m-d: a retried ApplyModule answers each installation once. +func TestApplyModuleIsSafeToRetry(t *testing.T) { + h := newHarness(t) + svc, err := authority.New(retrying{h.store}, h.cfg) + if err != nil { + t.Fatal(err) + } + store := domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 5, OwnerAware: true, + ResourceKinds: []*basev0.WorkScopeV1{sc(kind, "admin", "create", "read", "write")}} + ids, err := svc.ApplyModule(h.ctx, store) + if err != nil || len(ids) != 2 { + t.Fatalf("a retried apply answers %v (%v) for two organizations", ids, err) + } +} + +// m-e: a failed install step can be retried, and a different install of a +// pending target is refused naming the pending state. +func TestAFailedInstallStepCanBeRetried(t *testing.T) { + h := newHarness(t) + admin := h.admin(globex) + down := func(context.Context, domain.Installation) error { return errors.New("step down") } + if _, err := h.svc.Install(h.ctx, &admin, globex, "b", "author", admin.ID, down); err == nil { + t.Fatal("a failing step installed") + } + _, err := h.svc.Install(h.ctx, &admin, globex, "b", "viewer", admin.ID, nil) + refused(t, err, "pending") + inst, err := h.svc.Install(h.ctx, &admin, globex, "b", "author", admin.ID, nil) + if err != nil || inst.Status != domain.Active { + t.Fatalf("the retry: %+v %v", inst, err) + } +} + +// m-f: an installation's principal names no module, person or other +// installation, whatever principal source the host injects. +func TestAnInstallPrincipalIsItsOwn(t *testing.T) { + for _, taken := range []string{"store", "maya"} { + t.Run(taken, func(t *testing.T) { + h := newHarness(t) + cfg := h.cfg + cfg.PrincipalID = func(string, string) string { return taken } + svc, err := authority.New(h.store, cfg) + if err != nil { + t.Fatal(err) + } + admin := h.admin(globex) + _, err = svc.Install(h.ctx, &admin, globex, "b", "author", admin.ID, nil) + code(t, err, domain.Denied) + }) + } +} + +// failing is a store whose installation reads fail as an outage would. +type failing struct{ *memstore.Store } + +type failingTx struct{ authority.Tx } + +func (failingTx) Installation(string) (domain.Installation, error) { + return domain.Installation{}, errors.New("store unavailable") +} + +func (f failing) Read(ctx context.Context, fn func(authority.Tx) error) error { + return f.Store.Read(ctx, func(tx authority.Tx) error { return fn(failingTx{tx}) }) +} + +// m-g: a store outage is reported as one, never as a missing installation. +func TestAStoreOutageIsNotANoSeal(t *testing.T) { + h := newHarness(t) + svc, err := authority.New(failing{h.store}, h.cfg) + if err != nil { + t.Fatal(err) + } + _, err = svc.Seals().Seal(h.ctx, "maya", h.inst(acme, "a").ID) + if err == nil || errors.Is(err, workcontext.ErrNoSeal) { + t.Fatalf("an outage answered %v", err) + } +} diff --git a/libraries/authority-core/go/authority/race_test.go b/libraries/authority-core/go/authority/race_test.go new file mode 100644 index 00000000..dcdedab0 --- /dev/null +++ b/libraries/authority-core/go/authority/race_test.go @@ -0,0 +1,183 @@ +package authority_test + +import ( + "context" + "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" +) + +// racing is a store that, once armed, runs a change right after the first +// read that decided from role grants: the window between an issuance's +// decision and its mint. +type racing struct { + authority.Store + change func() +} + +type grantsTx struct { + authority.Tx + read *bool +} + +func (t grantsTx) RoleGrants(id string) ([]domain.RoleGrant, error) { + *t.read = true + return t.Tx.RoleGrants(id) +} + +func (r *racing) Read(ctx context.Context, fn func(authority.Tx) error) error { + read := false + err := r.Store.Read(ctx, func(tx authority.Tx) error { return fn(grantsTx{tx, &read}) }) + if read && r.change != nil { + change := r.change + r.change = nil + change() + } + return err +} + +// raced is a service over the harness's store whose next grant-deciding read +// is followed by change. +func (h *harness) raced(change func()) *authority.Service { + h.t.Helper() + svc, err := authority.New(&racing{Store: h.store, change: change}, h.cfg) + if err != nil { + h.t.Fatal(err) + } + return svc +} + +// B1: a revoke or a team leave committing between an issuance's decision and +// its mint never yields a capability: the mint is refused, in each path that +// decides from current grants. +func TestARevokeRacingAnIssueYieldsNoCapability(t *testing.T) { + revoke := func(h *harness) func() { return func() { h.revoke("a", "viewer", "maya", "") } } + leave := func(h *harness) func() { + return func() { + h.store.PutPerson(domain.Person{ID: "maya", Organization: acme}) + if err := h.svc.PersonChanged(h.ctx, "maya"); err != nil { + h.t.Fatal(err) + } + } + } + for name, setup := range map[string]struct { + grant func(h *harness) + change func(h *harness) func() + }{ + "a revoked role": {func(h *harness) { h.grant("a", "viewer", "maya") }, revoke}, + "a team left": {func(h *harness) { + h.store.PutPerson(domain.Person{ID: "maya", Organization: acme, Teams: []string{"team"}}) + h.grantTeam("a", "viewer", "team") + }, leave}, + } { + t.Run(name+" racing a person's issue", func(t *testing.T) { + h := newHarness(t) + setup.grant(h) + svc := h.raced(setup.change(h)) + out, err := svc.IssueForPerson(h.ctx, h.person("maya"), authority.IssueRequest{Audience: "a"}) + noCapability(t, h, out, err) + }) + t.Run(name+" racing a child", func(t *testing.T) { + h := newHarness(t) + setup.grant(h) + root := h.must(h.root("maya", "a")) + svc := h.raced(setup.change(h)) + out, err := svc.IssueChild(h.ctx, root.Token, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store"}) + noCapability(t, h, out, err) + }) + t.Run(name+" racing a delegation", func(t *testing.T) { + h := newHarness(t) + setup.grant(h) + d, err := h.svc.Delegate(h.ctx, h.person("maya"), "a", []*basev0.WorkScopeV1{owned(kind, h.principal(acme, "a"), read)}, "x") + if err != nil { + t.Fatal(err) + } + svc := h.raced(setup.change(h)) + out, err := svc.IssueForDelegation(h.ctx, d.ID, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store"}) + noCapability(t, h, out, err) + }) + } +} + +func noCapability(t *testing.T, h *harness, out authority.Issued, err error) { + t.Helper() + if err != nil { + return + } + if cerr := h.check(out); cerr == nil { + t.Fatalf("a capability minted across the change checks: %v", out.Context.GetAuthorityScopes()) + } +} + +// B2: a hop the installation made for itself through another module, refused +// once it is uninstalled, stays refused after a reinstall, even one holding a +// narrower role. +func TestAnUninstalledAgentsHopStaysDeadAfterReinstall(t *testing.T) { + h := newHarness(t) + agent := h.must(h.agent("a", "store")) + deeper := h.must(h.hop(agent, "self")) + if err := h.check(deeper); err != nil { + t.Fatal(err) + } + if err := h.svc.Uninstall(h.ctx, nil, acme, h.inst(acme, "a").ID); err != nil { + t.Fatal(err) + } + if h.check(deeper) == nil { + t.Fatal("the uninstalled agent's hop checks") + } + admin := h.admin(acme) + if _, err := h.svc.Install(h.ctx, &admin, acme, "a", "viewer", admin.ID, nil); err != nil { + t.Fatal(err) + } + if h.check(deeper) == nil { + t.Fatal("the reinstall revived the old agent's hop") + } +} + +// M1: a person's hop through the solution, sealed to another module's +// installation, ends with the uninstall. +func TestAnUninstallEndsEveryHopThroughTheSolution(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + deeper := h.must(h.hop(h.must(h.through("maya", "a", "store")), "self")) + if err := h.check(deeper); err != nil { + t.Fatal(err) + } + if err := h.svc.Uninstall(h.ctx, nil, acme, h.inst(acme, "a").ID); err != nil { + t.Fatal(err) + } + if h.check(deeper) == nil { + t.Fatal("a hop through the uninstalled solution checks") + } +} + +// m1: an installation issue naming another organization's installation and +// one naming none answer alike, with the id as asked. +func TestAnInstallationIssueAnswersForeignAsAbsent(t *testing.T) { + h := newHarness(t) + foreign := h.inst(globex, "a").ID + _, errForeign := h.svc.IssueForInstallation(h.ctx, authority.Workload{Module: "a"}, foreign, authority.IssueRequest{Organization: acme, Audience: "a"}) + _, errAbsent := h.svc.IssueForInstallation(h.ctx, authority.Workload{Module: "a"}, "no-such", authority.IssueRequest{Organization: acme, Audience: "a"}) + code(t, errForeign, domain.Denied) + code(t, errAbsent, domain.Denied) + if errForeign.Error() != "installation "+foreign+" belongs to another organization than \"acme\"" || + errAbsent.Error() != "installation no-such belongs to another organization than \"acme\"" { + t.Fatalf("foreign %q, absent %q", errForeign, errAbsent) + } +} + +// m4: Check verifies before it reads anything: a forged capability naming an +// owner and a binding is refused as unverified, never answered from the store. +func TestCheckVerifiesBeforeItReads(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + out := h.must(h.through("maya", "a", "store")) + forged := out.Token[:len(out.Token)-4] + "AAAA" + _, _, err := h.svc.Check(h.ctx, forged, "store") + if err == nil || strings.Contains(err.Error(), "owner-aware") { + t.Fatalf("a forged capability was answered from the store: %v", err) + } +} diff --git a/libraries/authority-core/go/authority/revocation_test.go b/libraries/authority-core/go/authority/revocation_test.go new file mode 100644 index 00000000..8e4ef190 --- /dev/null +++ b/libraries/authority-core/go/authority/revocation_test.go @@ -0,0 +1,154 @@ +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" +) + +func TestAGrantLeavesCapabilitiesValidAndARevokeEndsOnlyItsOwn(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + h.grant("b", "viewer", "maya") + h.grant("a", "author", "remi") + before := h.must(h.through("maya", "a", "store")) + throughB := h.must(h.through("maya", "b", "store")) + unrelated := h.must(h.through("remi", "a", "store")) + h.grant("a", "author", "maya") + h.grant("a", "viewer", "dev") + for _, c := range []authority.Issued{before, throughB, unrelated} { + if err := h.check(c); err != nil { + t.Fatalf("a new grant invalidated a capability: %v", err) + } + } + h.revoke("a", "author", "maya", "") + h.revoke("a", "viewer", "maya", "") + if err := h.check(before); err == nil { + t.Fatal("a capability through A survived its holder's revoke on A") + } + _, err := h.hop(before, "self") + if err == nil { + t.Fatal("a child of a revoked capability was issued") + } + _, err = h.root("maya", "a") + refused(t, err, "holds no role") + for name, c := range map[string]authority.Issued{"the same person through B": throughB, "another person through A": unrelated} { + if err := h.check(c); err != nil { + t.Fatalf("revoking a role on A invalidated %s: %v", name, err) + } + } + h.grant("a", "viewer", "maya") + h.must(h.through("maya", "a", "store")) + if err := h.check(before); err == nil { + t.Fatal("a re-grant revived an old capability") + } +} + +func TestATeamGrantIsItsMembersAccess(t *testing.T) { + h := newHarness(t) + h.grantTeam("a", "viewer", "team") + before := h.must(h.through("tam", "a", "store")) + h.revoke("a", "viewer", "", "team") + if err := h.check(before); err == nil { + t.Fatal("a team revoke left a member's capability valid") + } + h.grant("a", "viewer", "tam") + h.grantTeam("a", "viewer", "team") + h.revoke("a", "viewer", "", "team") + h.must(h.through("tam", "a", "store")) + leaving := h.must(h.through("tam", "a", "store")) + if err := h.svc.PersonChanged(h.ctx, "tam"); err != nil { + t.Fatal(err) + } + if err := h.check(leaving); err == nil { + t.Fatal("a person leaving kept a capability") + } +} + +func TestUninstallDisablesThePrincipalAndClearsGrants(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + h.grant("a", "author", "ada") + before := h.must(h.through("maya", "a", "store")) + parent := h.must(h.root("maya", "a")) + agent := h.must(h.agent("a", "store")) + inst := h.inst(acme, "a").ID + if err := h.svc.Uninstall(h.ctx, nil, acme, inst); err != nil { + t.Fatal(err) + } + for _, c := range []authority.Issued{before, agent} { + if err := h.check(c); err == nil { + t.Fatal("a capability survived the uninstall") + } + } + _, err := h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store"}) + if err == nil { + t.Fatal("a pre-uninstall parent derived a child") + } + _, err = h.agent("a", "store") + code(t, err, domain.Precondition) + _, err = h.root("maya", "a") + code(t, err, domain.Precondition) + _, err = h.svc.GrantRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: inst, Role: "viewer", PersonID: "maya"}) + refused(t, err, "not installed") + if again := h.install(acme, "a"); again.PrincipalID != h.principal(acme, "a") { + t.Fatal("the reinstalled principal changed") + } + _, err = h.root("maya", "a") + refused(t, err, "holds no role") + h.grant("a", "viewer", "maya") + exactly(t, h.must(h.through("maya", "a", "store")), kind, h.principal(acme, "a"), read) + if err := h.check(before); err == nil { + t.Fatal("reinstalling revived an old capability") + } +} + +func TestATransferIsAPersonsActOnWhatTheyAdminister(t *testing.T) { + h := newHarness(t) + u, v := h.principal(acme, "a"), h.principal(acme, "b") + h.grant("a", "editor", "ada") + asEditor := h.must(h.through("ada", "a", "store")) + transfer := func(capability authority.Issued, storeModule, id, from, to string) (authority.TransferDecision, error) { + return h.svc.AuthorizeTransfer(h.ctx, authority.TransferRequest{Token: capability.Token, Store: authority.Workload{Module: storeModule}, Kind: kind, ResourceID: id, From: from, To: to}) + } + _, err := transfer(asEditor, "store", "store-x", acme, u) + refused(t, err, "write on") + h.grant("a", "author", "ada") + for _, from := range []string{acme, "ada"} { + d, err := transfer(asEditor, "store", "store-x", from, u) + if err != nil || d.PrincipalID != u || d.AuthorizedBy != "ada" { + t.Fatalf("from %s: %+v %v", from, d, err) + } + } + h.grant("a", "author", "rob") + h.grant("a", domain.InstallerRole, "ines") + reads, installs := h.must(h.through("rob", "a", "store")), h.must(h.through("ines", "a", "store")) + agent := h.must(h.agent("a", "store")) + atRoot := h.must(h.root("ada", "a")) + for name, c := range map[string]struct { + capability authority.Issued + store, id, from, to, reason string + }{ + "from another solution principal": {asEditor, "store", "store-x", v, u, "solution principal"}, + "from another organization": {asEditor, "store", "store-x", globex, u, "not a person or the organization"}, + "from another organization's person": {asEditor, "store", "store-x", "omar", u, "not a person or the organization"}, + "from an unknown owner": {asEditor, "store", "store-x", "nobody", u, "not a person or the organization"}, + "from no owner": {asEditor, "store", "store-x", "", u, "not a person or the organization"}, + "a resource the person does not admin": {asEditor, "store", "store-y", acme, u, "own organization grants"}, + "to a principal it is not through": {asEditor, "store", "store-x", acme, v, "not issued through"}, + "by a reader": {reads, "store", "store-x", acme, u, "own organization grants"}, + "by the installer role alone": {installs, "store", "store-x", acme, u, "own organization grants"}, + "by the agent": {agent, "store", "store-x", acme, u, "a person's act"}, + "asked by a module that does not serve": {asEditor, "a", "store-x", acme, u, "does not serve"}, + "with a capability addressed elsewhere": {atRoot, "store", "store-x", acme, u, "audience"}, + "of the wildcard id": {asEditor, "store", "*", acme, u, "not a usable id"}, + "of an oversize id": {asEditor, "store", strings.Repeat("x", 129), acme, u, "not a usable id"}, + } { + t.Run(name, func(t *testing.T) { + _, err := transfer(c.capability, c.store, c.id, c.from, c.to) + refused(t, err, c.reason) + }) + } +} diff --git a/libraries/authority-core/go/authority/roles.go b/libraries/authority-core/go/authority/roles.go index 27bfeda6..72f5c81c 100644 --- a/libraries/authority-core/go/authority/roles.go +++ b/libraries/authority-core/go/authority/roles.go @@ -21,9 +21,13 @@ func (r RoleRequest) grant() domain.RoleGrant { // decideRoleChange reads what decide.ChangeRole needs. func decideRoleChange(tx Tx, caller Person, r RoleRequest, list, revoke bool) (domain.Installation, error) { - // The installation is resolved by (organization, id) before anything else - // is read: another organization's and an absent one answer alike, having - // done the same work. + // 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. + if callerOrganization(tx, caller) != r.Organization { + 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) { return inst, decide.ForeignInstallation(r.InstallationID, r.Organization) @@ -130,3 +134,12 @@ 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; a caller the store no longer knows keeps the session's. +func callerOrganization(tx Tx, caller Person) string { + if p, err := tx.Person(caller.ID); err == nil { + return p.Organization + } + return caller.Organization +} diff --git a/libraries/authority-core/go/authority/service.go b/libraries/authority-core/go/authority/service.go index c35b504b..0b7c1ee6 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -11,6 +11,7 @@ import ( "crypto/ed25519" "errors" "fmt" + "slices" "strings" "time" @@ -150,12 +151,14 @@ func (noGrants) Grant(_ context.Context, id string) (*workcontext.Grant, error) // capability sealed to it: the installation's own revision, the owner's // grant epoch on it, and the installed solution's owner-aware revision. // -// The sum never repeats a value it held before, because each term only grows -// and is keyed stably: an installation's revision grows on install, -// uninstall and narrowing; a grant epoch grows on revoke; an owner-aware -// revision grows on withdrawal and is keyed by the module's principal, which -// a module keeps for life (ApplyModule refuses a change). A capability sealed -// at an earlier sum is therefore refused for good. +// The sum never goes back to a value it held before, because each term only +// grows and is keyed stably: an installation's revision grows on install, +// uninstall, narrowing and a withdrawn module's redelivery; a grant epoch +// grows on revoke; an owner-aware revision grows on withdrawal and is keyed +// by the module's principal, which a module keeps for life, withdrawn and +// delivered again included (ApplyModule refuses a rename, against its +// tombstone once withdrawn). A module's generation never rewinds either. A +// capability sealed at an earlier sum is therefore refused for good. type sources struct{ s *Service } func (src sources) AuthorizationRevision(ctx context.Context, tenant string) (uint64, error) { @@ -169,75 +172,128 @@ func (src sources) AuthorizationRevision(ctx context.Context, tenant string) (ui func (src sources) Seal(ctx context.Context, principal, installationID string) (workcontext.Seal, error) { var seal workcontext.Seal - err := src.s.store.Read(ctx, func(tx Tx) error { - inst, err := tx.Installation(installationID) - if err != nil || inst.Status != domain.Active { - return workcontext.ErrNoSeal - } - if !src.holds(tx, principal, inst) { - return workcontext.ErrNoSeal - } - epoch, err := tx.GrantEpoch(principal, inst.ID) - if err != nil { - return err - } - aware := uint64(0) - if m, err := tx.ModuleNamed(inst.Target); err == nil { - if aware, err = tx.OwnerAwareRevision(m.Principal); err != nil { - return err - } - } - seal = workcontext.Seal{InstallationID: inst.ID, InstallationRevision: inst.Revision + epoch + aware} - return nil + err := src.s.store.Read(ctx, func(tx Tx) (err error) { + seal, err = src.sealIn(tx, principal, installationID) + return err }) return seal, err } +func (src sources) sealIn(tx Tx, principal, installationID string) (workcontext.Seal, error) { + inst, err := tx.Installation(installationID) + if errors.Is(err, domain.ErrNotFound) || (err == nil && inst.Status != domain.Active) { + return workcontext.Seal{}, workcontext.ErrNoSeal + } + if err != nil { + return workcontext.Seal{}, err // the store's own failure, named as such + } + held, err := src.holds(tx, principal, inst) + if err != nil { + return workcontext.Seal{}, err + } + if !held { + return workcontext.Seal{}, workcontext.ErrNoSeal + } + epoch, err := tx.GrantEpoch(principal, inst.ID) + if err != nil { + return workcontext.Seal{}, err + } + aware := uint64(0) + if m, err := tx.ModuleNamed(inst.Target); err == nil { + if aware, err = tx.OwnerAwareRevision(m.Principal); err != nil { + return workcontext.Seal{}, err + } + } + return workcontext.Seal{InstallationID: inst.ID, InstallationRevision: inst.Revision + epoch + aware}, nil +} + // holds: the principal may use the installation: a person of its // organization, a composed module, its own principal, or a solution // principal of the same organization reaching a module installation. -func (src sources) holds(tx Tx, principal string, inst domain.Installation) bool { +func (src sources) holds(tx Tx, principal string, inst domain.Installation) (bool, error) { if principal == inst.PrincipalID && principal != "" { - return true + return true, nil } if _, err := tx.Module(principal); err == nil { - return true + return true, nil + } else if !errors.Is(err, domain.ErrNotFound) { + return false, err } if p, err := tx.Person(principal); err == nil { - return p.Organization == inst.Organization + return p.Organization == inst.Organization, nil + } else if !errors.Is(err, domain.ErrNotFound) { + return false, err } source, err := tx.InstallationByPrincipal(principal) - return err == nil && source.Status == domain.Active && source.Organization == inst.Organization + if errors.Is(err, domain.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return source.Status == domain.Active && source.Organization == inst.Organization, nil } func (src sources) PrincipalEpoch(ctx context.Context, principal string) (uint64, error) { var epoch uint64 - err := src.s.store.Read(ctx, func(tx Tx) error { - if m, err := tx.Module(principal); err == nil { - epoch = m.Generation - return nil - } - if _, err := tx.Person(principal); err == nil { - var perr error - epoch, perr = tx.PersonEpoch(principal) - return perr - } - if inst, err := tx.InstallationByPrincipal(principal); err == nil && inst.Status == domain.Active { - // The installation's revision: it grows on every install and - // uninstall, so a reinstall never repeats an epoch an earlier - // incarnation's capabilities were sealed at. - epoch = inst.Revision - return nil - } - return domain.Missing("principal %q", principal) + err := src.s.store.Read(ctx, func(tx Tx) (err error) { + epoch, err = src.epochIn(tx, principal) + return err }) return epoch, err } +func (src sources) epochIn(tx Tx, principal string) (uint64, error) { + if m, err := tx.Module(principal); err == nil { + return m.Generation, nil + } + if _, err := tx.Person(principal); err == nil { + return tx.PersonEpoch(principal) + } + if inst, err := tx.InstallationByPrincipal(principal); err == nil && inst.Status == domain.Active { + // The installation's revision: it grows on every install and + // uninstall, so a reinstall never repeats an epoch an earlier + // incarnation's capabilities were sealed at. + return inst.Revision, nil + } + return 0, domain.Missing("principal %q", principal) +} + +// fence is what an issuance decided against: the organization's +// authorization revision, and the owner's seal on the installation and +// epoch, read in the decision's own snapshot. A capability minted after a +// revoke, a leave or an uninstall committed carries other values, and is +// refused rather than handed out. +type fence struct{ revision, seal, epoch uint64 } + +func (src sources) fenceIn(tx Tx, organization, owner, installationID string) (fence, error) { + revision, err := tx.AuthorizationRevision(organization) + if err != nil { + return fence{}, err + } + seal, err := src.sealIn(tx, owner, installationID) + if err != nil { + return fence{}, domain.Refuse("%s may not use installation %s now", owner, installationID) + } + epoch, err := src.epochIn(tx, owner) + if err != nil { + return fence{}, err + } + return fence{revision, seal.InstallationRevision, epoch}, nil +} + +func (f fence) holds(signed *basev0.WorkContextV1) error { + if signed.GetAuthorizationRevision() != f.revision || signed.GetSeal().GetInstallationRevision() != f.seal || signed.GetSeal().GetPrincipalEpoch() != f.epoch { + return domain.Refuse("permissions changed during issuance") + } + return nil +} + // OperationBinding answers "//". Its -// incarnation is the binding's audience's owner-aware revision, plus one: a -// capability through it issued before the audience last withdrew owner-aware -// checking is refused for good. +// incarnation is the binding's audience's owner-aware revision plus the +// acting module's installation revision, both only growing: a capability +// through it issued before the audience last withdrew owner-aware checking, +// or before the installation was last uninstalled, is refused for good. func (src sources) OperationBinding(ctx context.Context, id string) (workcontext.OperationBinding, error) { var out workcontext.OperationBinding err := src.s.store.Read(ctx, func(tx Tx) error { @@ -262,7 +318,7 @@ func (src sources) OperationBinding(ctx context.Context, id string) (workcontext return err } out = workcontext.OperationBinding{ID: id, PrincipalID: m.Principal, InstallationID: inst.ID, - Revision: m.Generation, Incarnation: aware + 1, Revoked: inst.Status == domain.Revoked} + Revision: m.Generation, Incarnation: aware + inst.Revision, Revoked: inst.Status == domain.Revoked} return nil }) return out, err @@ -289,5 +345,34 @@ func notFound(err error) error { return err } +// orgRoles are a person's organization roles, those the catalogue defines. +func orgRoles(tx Tx, p domain.Person) ([]domain.OrgRole, error) { + var out []domain.OrgRole + for _, name := range p.Roles { + r, err := tx.OrgRole(p.Organization, name) + if errors.Is(err, domain.ErrNotFound) { + continue + } + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, nil +} + +func clamp(ttl, max time.Duration) time.Duration { + if ttl <= 0 || ttl > max { + return max + } + return ttl +} + +func sortedTeams(teams []string) []string { + out := slices.Clone(teams) + slices.Sort(out) + return slices.Compact(out) +} + // Scopes is a scope list. type Scopes = []*basev0.WorkScopeV1 diff --git a/libraries/authority-core/go/authority/store.go b/libraries/authority-core/go/authority/store.go index 4d6710d0..37d02bbe 100644 --- a/libraries/authority-core/go/authority/store.go +++ b/libraries/authority-core/go/authority/store.go @@ -37,6 +37,9 @@ type Tx interface { Modules() ([]domain.Module, error) PutModule(domain.Module) error DeleteModule(name string) error + // Tombstone answers what stays of a withdrawn module, by name. + Tombstone(name string) (domain.Tombstone, error) + PutTombstone(domain.Tombstone) error Installation(id string) (domain.Installation, error) InstallationByPrincipal(principal string) (domain.Installation, error) diff --git a/libraries/authority-core/go/authority/transfer.go b/libraries/authority-core/go/authority/transfer.go new file mode 100644 index 00000000..2724a733 --- /dev/null +++ b/libraries/authority-core/go/authority/transfer.go @@ -0,0 +1,129 @@ +package authority + +import ( + "context" + "errors" + "slices" + + "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" +) + +// TransferRequest asks whether an existing resource may join a solution +// principal: the person's capability, the store attesting the resource and +// its current owner. +type TransferRequest struct { + Token, Kind, ResourceID, From, To string + Store Workload +} + +// TransferDecision is what the store records. +type TransferDecision struct { + PrincipalID, AuthorizedBy string +} + +// AuthorizeTransfer decides a transfer, reading everything live. The caller +// is the kind's one serving module; the capability is addressed to it. +func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (TransferDecision, error) { + if err := decide.ValidResourceID(r.ResourceID); err != nil { + return TransferDecision{}, err + } + var store string + err := s.store.Read(ctx, func(tx Tx) error { + m, err := tx.ModuleNamed(r.Store.Module) + if err != nil { + return notFound(err) + } + servers, err := servingModules(tx, r.Kind) + if err != nil { + return err + } + if !slices.Contains(servers, m.Name) || len(servers) != 1 { + return domain.Refuse("%s does not serve %s alone, so it attests none of its resources", m.Name, r.Kind) + } + store = m.Principal + return nil + }) + if err != nil { + return TransferDecision{}, err + } + verified, err := s.Verify(ctx, r.Token, store) + if err != nil { + return TransferDecision{}, err + } + wc := verified.Context() + var decision TransferDecision + err = s.store.Read(ctx, func(tx Tx) error { + t := decide.Transfer{OwnerKind: wc.GetOwnerPrincipalKind(), From: r.From, To: r.To} + if t.OwnerKind != "human" { + return decide.AuthorizeTransfer(t) + } + p, err := tx.Person(wc.GetOwnerPrincipalId()) + if err != nil { + return domain.Refuse("the owner %s is no longer a member", wc.GetOwnerPrincipalId()) + } + t.Person = p + for _, principal := range solutionPrincipals(wc, wc.GetAudience()) { + if m, err := tx.Module(principal); err == nil && m.Kind == domain.KindSolution { + if inst, err := installationOf(tx, wc.GetTenantId(), m.Name); err == nil { + t.Through = append(t.Through, inst.PrincipalID) + } + } + } + if _, err := tx.InstallationByPrincipal(r.From); err == nil { + t.FromPrincipal = true + } else if !errors.Is(err, domain.ErrNotFound) { + return err + } + if fp, err := tx.Person(r.From); err == nil { + t.FromPerson = &fp + } + roles, err := orgRoles(tx, p) + if err != nil { + return err + } + if matchers := decide.ResourceGrants(roles, r.Kind, "admin"); len(matchers) > 0 { + covered, err := resources.Compile(matchers) + t.Administers = err == nil && covered.Matches(r.ResourceID) + } + if target, err := tx.InstallationByPrincipal(r.To); err == nil { + solution, err := tx.ModuleNamed(target.Target) + if err != nil { + return notFound(err) + } + grants, err := tx.RoleGrants(target.ID) + if err != nil { + return err + } + role, err := decide.RoleScopes(solution, target, decide.HeldRoles(p, target, grants)) + if err != nil { + return err + } + t.Writes = scope.HoldsAction(role, r.Kind, "write") + } + if err := decide.AuthorizeTransfer(t); err != nil { + return err + } + decision = TransferDecision{PrincipalID: r.To, AuthorizedBy: p.ID} + return nil + }) + return decision, err +} + +// servingModules are the modules declaring kind. +func servingModules(tx Tx, kind string) ([]string, error) { + all, err := tx.Modules() + if err != nil { + return nil, err + } + var out []string + for _, m := range all { + if slices.ContainsFunc(m.ResourceKinds, func(sc *basev0.WorkScopeV1) bool { return sc.GetResourceKind() == kind }) { + out = append(out, m.Name) + } + } + return out, nil +} diff --git a/libraries/authority-core/go/domain/domain.go b/libraries/authority-core/go/domain/domain.go index aa121b02..cc06d1c1 100644 --- a/libraries/authority-core/go/domain/domain.go +++ b/libraries/authority-core/go/domain/domain.go @@ -77,6 +77,13 @@ type MethodPolicy struct { AnyCheckedCaller bool } +// Tombstone is what stays of a withdrawn module: its principal, which it +// keeps for life, and the generation a redelivery must exceed. +type Tombstone struct { + Name, Principal string + Generation uint64 +} + // Status is an installation's lifecycle. type Status string diff --git a/libraries/authority-core/go/memstore/memstore.go b/libraries/authority-core/go/memstore/memstore.go index b395050b..4dd16412 100644 --- a/libraries/authority-core/go/memstore/memstore.go +++ b/libraries/authority-core/go/memstore/memstore.go @@ -31,6 +31,7 @@ type Store struct { grantEpochs map[string]uint64 ownerAware map[string]uint64 delegations map[string]domain.Delegation + tombstones map[string]domain.Tombstone } // New is an empty store. @@ -39,7 +40,7 @@ func New() *Store { orgs: map[string]domain.Organization{}, people: map[string]domain.Person{}, roles: map[string]domain.OrgRole{}, modules: map[string]domain.Module{}, installations: map[string]domain.Installation{}, grants: map[string][]domain.RoleGrant{}, revisions: map[string]uint64{}, personEpochs: map[string]uint64{}, grantEpochs: map[string]uint64{}, - ownerAware: map[string]uint64{}, delegations: map[string]domain.Delegation{}, + ownerAware: map[string]uint64{}, delegations: map[string]domain.Delegation{}, tombstones: map[string]domain.Tombstone{}, } } @@ -52,16 +53,21 @@ func (s *Store) Read(_ context.Context, fn func(authority.Tx) error) error { return fn(tx{s: s}) } -// Write runs fn under the write lock, as one transaction: when fn fails, -// every write it made is undone. -func (s *Store) Write(_ context.Context, fn func(authority.Tx) error) error { +// Write runs fn under the write lock, as one transaction: when fn fails or +// panics, every write it made is undone. +func (s *Store) Write(_ context.Context, fn func(authority.Tx) error) (err error) { s.mu.Lock() defer s.mu.Unlock() - before := s.snapshot() - if err := fn(tx{s: s, write: true}); err != nil { - s.restore(before) + before, committed := s.snapshot(), false + defer func() { + if !committed { + s.restore(before) + } + }() + if err = fn(tx{s: s, write: true}); err != nil { return err } + committed = true return nil } @@ -76,6 +82,7 @@ type state struct { grantEpochs map[string]uint64 ownerAware map[string]uint64 delegations map[string]domain.Delegation + tombstones map[string]domain.Tombstone } func (s *Store) snapshot() state { @@ -85,12 +92,13 @@ func (s *Store) snapshot() state { } return state{modules: maps.Clone(s.modules), installations: maps.Clone(s.installations), grants: grants, revisions: maps.Clone(s.revisions), personEpochs: maps.Clone(s.personEpochs), grantEpochs: maps.Clone(s.grantEpochs), - ownerAware: maps.Clone(s.ownerAware), delegations: maps.Clone(s.delegations)} + ownerAware: maps.Clone(s.ownerAware), delegations: maps.Clone(s.delegations), tombstones: maps.Clone(s.tombstones)} } func (s *Store) restore(b state) { s.modules, s.installations, s.grants, s.revisions = b.modules, b.installations, b.grants, b.revisions s.personEpochs, s.grantEpochs, s.ownerAware, s.delegations = b.personEpochs, b.grantEpochs, b.ownerAware, b.delegations + s.tombstones = b.tombstones } // PutOrganization, PutPerson, DeletePerson and PutOrgRole seed and change @@ -217,6 +225,20 @@ func (t tx) DeleteModule(name string) error { return nil } +func (t tx) Tombstone(name string) (domain.Tombstone, error) { + tomb, ok := t.s.tombstones[name] + if !ok { + return tomb, missing("tombstone", name) + } + return tomb, nil +} + +func (t tx) PutTombstone(tomb domain.Tombstone) error { + t.mustWrite() + t.s.tombstones[tomb.Name] = tomb + return nil +} + func (t tx) Installation(id string) (domain.Installation, error) { i, ok := t.s.installations[id] if !ok {