From a7ce42da9382e911afc04162c9d5ad7000c4dfea Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:44:25 -0400 Subject: [PATCH 1/6] feat: the authority core's service over a Store, and an in-memory store The service runs every operation as reads from a Store, a decision (package decide), writes, and a capability minted or verified with core's workcontext. The signer and keys are the host's (Config.Signer, Keys); the clock and id sources are injected. Seal, epoch, binding and revision state answer core's verifier from the store: a revoke bumps the (person, installation) epoch, leaving a team or the organization the person epoch, and withdrawing owner-aware checking the audience's owner-aware revision, carried as the binding's incarnation and folded into a solution's seal. memstore is the in-memory Store. Tests encode the conformance target over it. Co-Authored-By: Claude Opus 5.5 --- .../authority-core/go/authority/check.go | 163 +++++ .../authority-core/go/authority/delegation.go | 197 ++++++ .../go/authority/harness_test.go | 244 +++++++ .../authority-core/go/authority/issue.go | 616 ++++++++++++++++++ .../authority-core/go/authority/lifecycle.go | 223 +++++++ .../go/authority/principal_test.go | 219 +++++++ .../go/authority/revocation_test.go | 154 +++++ .../authority-core/go/authority/roles.go | 126 ++++ .../authority-core/go/authority/service.go | 317 +++++++++ .../authority-core/go/authority/store.go | 62 ++ .../authority-core/go/authority/transfer.go | 125 ++++ libraries/authority-core/go/go.mod | 9 + libraries/authority-core/go/go.sum | 24 + .../authority-core/go/memstore/memstore.go | 336 ++++++++++ 14 files changed, 2815 insertions(+) create mode 100644 libraries/authority-core/go/authority/check.go create mode 100644 libraries/authority-core/go/authority/delegation.go create mode 100644 libraries/authority-core/go/authority/harness_test.go create mode 100644 libraries/authority-core/go/authority/issue.go create mode 100644 libraries/authority-core/go/authority/lifecycle.go create mode 100644 libraries/authority-core/go/authority/principal_test.go create mode 100644 libraries/authority-core/go/authority/revocation_test.go create mode 100644 libraries/authority-core/go/authority/roles.go create mode 100644 libraries/authority-core/go/authority/service.go create mode 100644 libraries/authority-core/go/authority/store.go create mode 100644 libraries/authority-core/go/authority/transfer.go create mode 100644 libraries/authority-core/go/memstore/memstore.go diff --git a/libraries/authority-core/go/authority/check.go b/libraries/authority-core/go/authority/check.go new file mode 100644 index 00000000..4dd98de1 --- /dev/null +++ b/libraries/authority-core/go/authority/check.go @@ -0,0 +1,163 @@ +package authority + +import ( + "context" + "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) { + if decoded, err := workcontext.Decode(token); err == nil { + wc := decoded.Context() + if scope.HasOwner(effectiveOf(wc)) && wc.GetOperationBinding().GetBindingId() != "" { + if current, err := s.src.OperationBinding(ctx, wc.GetOperationBinding().GetBindingId()); err == nil && current.Incarnation != wc.GetOperationBinding().GetIncarnation() { + return nil, 0, domain.Refuse("issued before %s last withdrew owner-aware checking; only a fresh capability carries owner-selected scopes", audience) + } + } + } + verified, err := s.Verify(ctx, token, 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/delegation.go b/libraries/authority-core/go/authority/delegation.go new file mode 100644 index 00000000..c318b099 --- /dev/null +++ b/libraries/authority-core/go/authority/delegation.go @@ -0,0 +1,197 @@ +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 revision uint64 + 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 revision, err = tx.AuthorizationRevision(d.Organization); err != nil { + return err + } + 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 + return nil + }) + if err != nil { + return Issued{}, err + } + root, err := s.startAtRevision(ctx, revision, 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 new file mode 100644 index 00000000..98c5bbed --- /dev/null +++ b/libraries/authority-core/go/authority/harness_test.go @@ -0,0 +1,244 @@ +package authority_test + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "strings" + "testing" + "time" + + "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" + resources "github.com/codefly-dev/authority/libraries/resources/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// The fixture mirrors the conformance target (RunSolutionPrincipalTarget): two +// solutions A and B and a solution C that checks no owners, a store serving +// "collection" (owner-aware) and a legacy store (not), people at acme and globex. +const ( + kind, legacyKind = "collection", "legacy" + acme, globex = "acme", "globex" +) + +type keys struct{ public ed25519.PublicKey } + +func (k keys) PublicKeys(context.Context) (map[string]ed25519.PublicKey, error) { + return map[string]ed25519.PublicKey{"k1": k.public}, nil +} + +type harness struct { + t *testing.T + store *memstore.Store + svc *authority.Service + now time.Time + ctx context.Context +} + +func sc(k string, actions ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: k, Actions: actions} +} + +func owned(k, owner string, actions []string, ids ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: k, Actions: actions, ResourceIds: ids, ResourceOwnerId: owner} +} + +func solution(name string, ownerAware bool) domain.Module { + own := name + ".note" + return domain.Module{Name: name, Principal: name, Kind: domain.KindSolution, Generation: 1, OwnerAware: ownerAware, + ResourceKinds: []*basev0.WorkScopeV1{sc(own, "read", "write")}, + Bindings: map[string]domain.Binding{ + "store": {ID: "store", Audience: "store", InvokeScopes: []*basev0.WorkScopeV1{sc(kind, "admin", "create", "read", "write")}}, + "legacy": {ID: "legacy", Audience: "legacy-store", InvokeScopes: []*basev0.WorkScopeV1{sc(legacyKind, "read")}}, + }, + Roles: map[string][]*basev0.WorkScopeV1{ + domain.InstallerRole: {sc(own, "read", "write"), sc(kind, "admin", "create", "read", "write")}, + "author": {sc(own, "read", "write"), sc(kind, "create", "read", "write")}, + "editor": {sc(own, "read"), sc(kind, "read")}, + "viewer": {sc(own, "read"), sc(kind, "read"), sc(legacyKind, "read")}, + }} +} + +func newHarness(t *testing.T) *harness { + t.Helper() + public, private, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + h := &harness{t: t, store: memstore.New(), now: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), ctx: context.Background()} + n := 0 + h.svc, err = authority.New(h.store, authority.Config{Issuer: "https://authority.test", KeyID: "k1", Signer: private, Keys: keys{public}, + Clock: func() time.Time { return h.now }, NewID: func() string { n++; return fmt.Sprintf("id-%d", n) }}) + if err != nil { + t.Fatal(err) + } + for _, org := range []string{acme, globex} { + h.store.PutOrganization(domain.Organization{ID: org, Name: org}) + } + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "admin", Scopes: nil}) + h.store.PutOrgRole(domain.OrgRole{Organization: globex, Name: "admin", Scopes: nil}) + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "store-admin", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"admin"}, Resources: []resources.Grant{{ID: "store-x"}}}}}) + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "store-reader", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"read"}, Resources: []resources.Grant{{ID: "store-x"}}}}}) + for _, p := range []domain.Person{ + {ID: "alex", Organization: acme, Roles: []string{"admin"}}, + {ID: "maya", Organization: acme}, {ID: "remi", Organization: acme}, {ID: "dev", Organization: acme}, + {ID: "ines", Organization: acme}, {ID: "tam", Organization: acme, Teams: []string{"team"}}, {ID: "tal", Organization: acme, Teams: []string{"team"}}, + {ID: "ada", Organization: acme, Roles: []string{"store-admin"}}, {ID: "rob", Organization: acme, Roles: []string{"store-reader"}}, + {ID: "zoe", Organization: globex, Roles: []string{"admin"}}, {ID: "omar", Organization: globex, Teams: []string{"ops"}}, + } { + h.store.PutPerson(p) + } + for _, m := range []domain.Module{ + {Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 1, OwnerAware: true, + 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")}}, + }}, + {Name: "legacy-store", Principal: "legacy-store", Kind: domain.KindModule, Generation: 1, ResourceKinds: []*basev0.WorkScopeV1{sc(legacyKind, "read")}}, + solution("a", true), solution("b", true), solution("c", false), + } { + if _, err := h.svc.ApplyModule(h.ctx, m); err != nil { + t.Fatal(err) + } + } + h.install(acme, "a") + h.install(acme, "b") + h.install(acme, "c") + h.install(globex, "a") + return h +} + +func (h *harness) person(id string) authority.Person { + org := acme + if id == "zoe" || id == "omar" { + org = globex + } + return authority.Person{ID: id, Organization: org, SessionID: "session-" + id} +} + +func (h *harness) admin(org string) authority.Person { + if org == globex { + return h.person("zoe") + } + return h.person("alex") +} + +func (h *harness) install(org, target string) domain.Installation { + h.t.Helper() + admin := h.admin(org) + inst, err := h.svc.Install(h.ctx, &admin, org, target, "author", admin.ID, nil) + if err != nil { + h.t.Fatal(err) + } + return inst +} + +func (h *harness) inst(org, target string) domain.Installation { + return domain.Installation{ID: authority.StableInstallationID(org, target), PrincipalID: authority.StablePrincipalID(org, target)} +} + +func (h *harness) principal(org, target string) string { return h.inst(org, target).PrincipalID } + +func (h *harness) grant(target, role, person string) { + h.t.Helper() + if _, err := h.svc.GrantRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, target).ID, Role: role, PersonID: person}); err != nil { + h.t.Fatal(err) + } +} + +func (h *harness) grantTeam(target, role, team string) { + h.t.Helper() + if _, err := h.svc.GrantRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, target).ID, Role: role, TeamID: team}); err != nil { + h.t.Fatal(err) + } +} + +func (h *harness) revoke(target, role, person, team string) { + h.t.Helper() + if _, err := h.svc.RevokeRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, target).ID, Role: role, PersonID: person, TeamID: team}); err != nil { + h.t.Fatal(err) + } +} + +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() + if err == nil || !strings.Contains(err.Error(), reason) { + t.Fatalf("want a refusal naming %q, got %v", reason, err) + } +} + +func code(t *testing.T, err error, want domain.Code) { + t.Helper() + if got, ok := domain.CodeOf(err); !ok || got != want { + t.Fatalf("want code %v, got %v (%v)", want, got, err) + } +} + +// 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..37a6c911 --- /dev/null +++ b/libraries/authority-core/go/authority/issue.go @@ -0,0 +1,616 @@ +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 + } + return scope.Intersect(decide.RoleScopes(audience, inst, decide.HeldRoles(p, grants)), 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 + } + role = decide.RoleScopes(m, inst, decide.HeldRoles(p, grants)) + } 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 +} + +// startAtRevision mints at the revision the decision read: a concurrent grant +// change must not turn old scopes into fresh authority. +func (s *Service) startAtRevision(ctx context.Context, revision uint64, in workcontext.StartInput) (Issued, error) { + token, signed, err := s.authority.Start(ctx, in) + if err != nil { + return Issued{}, err + } + if signed.GetAuthorizationRevision() != revision { + return Issued{}, domain.Refuse("permissions changed during issuance") + } + 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 revision uint64 + 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) + } + if revision, err = tx.AuthorizationRevision(p.Organization); err != nil { + return err + } + 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 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.startAtRevision(ctx, revision, 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 revision uint64 + 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) + } + if inst, err = tx.Installation(installationID); err != nil { + return notFound(err) + } + if r.Organization != "" && r.Organization != inst.Organization { + return domain.Refuse("installation %s belongs to organization %q, not %q", inst.ID, inst.Organization, r.Organization) + } + 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 revision, err = tx.AuthorizationRevision(inst.Organization); 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 + } + heldHere = scope.Intersect(decide.RoleScopes(solution, inst, []string{inst.AgentRole}), 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.startAtRevision(ctx, revision, 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()) + } + err = s.store.Read(ctx, func(tx Tx) error { + tenant := claims.GetTenantId() + var heldHere, sourceCeiling, own 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 + } + if bounded { + roles, err := orgRoles(tx, p) + if err != nil { + return err + } + own = decide.OrgScopes(roles, audience) + } + 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) + } + heldHere = scope.Intersect(decide.RoleScopes(solution, inst, []string{inst.AgentRole}), 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.Scopes, ceiling, scope.Intersect(own, through), bounded, r.Binding) + } + 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 + return nil + }) + if err != nil { + return Issued{}, err + } + return s.derive(ctx, verified, am, audience, r, scopes, authorityScopes, sealTo) +} + +// 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) (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") + } + 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 new file mode 100644 index 00000000..a2265b3e --- /dev/null +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -0,0 +1,223 @@ +package authority + +import ( + "context" + "errors" + "maps" + "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" +) + +// ApplyModule records a delivered module at a newer generation and holds a +// module installation at every organization (a solution is installed +// explicitly). A narrowing invalidates the capabilities issued before it; a +// withdrawn owner-aware declaration refuses its owner-selected ones for good. +func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, error) { + if err := decide.ValidateModule(m); err != nil { + return nil, err + } + var ids []string + err := s.store.Write(ctx, func(tx Tx) error { + previous, err := tx.ModuleNamed(m.Name) + exists := err == nil + if 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) + } + if other, err := tx.Module(m.Principal); err == nil && other.Name != m.Name { + return domain.Refuse("principal %q already belongs to %s", m.Principal, other.Name) + } + m.ResourceKinds = scope.Normalize(m.ResourceKinds) + if exists && decide.Narrowed(previous, m) { + all, err := tx.Installations("") + if err != nil { + return err + } + changed := map[string]bool{} + for _, inst := range all { + if inst.Target == m.Name && inst.Status == domain.Active { + inst.Revision++ + if err := tx.PutInstallation(inst); err != nil { + return err + } + changed[inst.Organization] = true + } + } + for _, org := range slices.Sorted(maps.Keys(changed)) { + if err := tx.BumpAuthorizationRevision(org); err != nil { + return err + } + } + } + if exists && previous.OwnerAware && !m.OwnerAware { + if err := tx.BumpOwnerAwareRevision(m.Principal); err != nil { + return err + } + } + if err := tx.PutModule(m); err != nil { + return err + } + if m.Kind == domain.KindSolution { + return nil + } + orgs, err := tx.Organizations() + if err != nil { + return err + } + for _, org := range orgs { + id := s.cfg.InstallationID(org.ID, m.Name) + if _, err := tx.Installation(id); 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 + } + } + ids = append(ids, id) + } + slices.Sort(ids) + return nil + }) + return ids, err +} + +// WithdrawModule removes a module: its installations are revoked and the +// delegations granted to it end. +func (s *Service) WithdrawModule(ctx context.Context, name string) error { + return s.store.Write(ctx, func(tx Tx) error { + all, err := tx.Installations("") + if err != nil { + return err + } + for _, inst := range all { + if inst.Target == name && inst.Status != domain.Revoked { + if err := s.revoke(tx, inst); err != nil { + return err + } + } + } + delegations, err := tx.Delegations() + if err != nil { + return err + } + for _, d := range delegations { + if d.Module == name && !d.Revoked { + d.Revoked = true + if err := tx.PutDelegation(d); err != nil { + return err + } + if err := tx.BumpAuthorizationRevision(d.Organization); err != nil { + return err + } + } + } + return tx.DeleteModule(name) + }) +} + +// InstallStep converges one installation; a retry may repeat it. +type InstallStep func(ctx context.Context, inst domain.Installation) error + +// Install installs a solution at an organization: pending until its step +// converges, then active. Its principal is stable per organization and +// target, so a reinstall reuses it; nobody holds a role until granted one, +// the installing person included. A caller, when named, must administer the +// organization; a host's own setup names none. +func (s *Service) Install(ctx context.Context, caller *Person, org, target, agentRole, installedBy string, step InstallStep) (domain.Installation, error) { + var inst domain.Installation + var generation uint64 + err := s.store.Write(ctx, func(tx Tx) error { + if caller != nil { + p, err := tx.Person(caller.ID) + if err != nil || p.Organization != org || !decide.IsAdministrator(p) { + return domain.Refuse("installation administration requires an administrator or owner of organization %q", org) + } + } + if _, err := tx.Organization(org); err != nil { + return notFound(err) + } + m, err := tx.ModuleNamed(target) + if err != nil || m.Kind != domain.KindSolution { + return domain.Missing("%q is not a composed solution; a module is installed by composition", target) + } + generation = m.Generation + if _, ok := m.Roles[agentRole]; !ok { + return domain.Missing("role %q is not one %s declares", agentRole, target) + } + if p, err := tx.Person(installedBy); err != nil || p.Organization != org { + return domain.Refuse("owner %q is not a member of %s", installedBy, org) + } + id := s.cfg.InstallationID(org, target) + existing, err := tx.Installation(id) + if err == nil && existing.Status != domain.Revoked { + return domain.Refuse("%s is already installed at %s", 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} + if err := tx.DeleteRoleGrants(id); err != nil { + return err + } + return tx.PutInstallation(inst) + }) + if err != nil { + return inst, err + } + if step != nil { + if err := step(ctx, inst); err != nil { + return inst, err + } + } + err = s.store.Write(ctx, func(tx Tx) error { + current, err := tx.Installation(inst.ID) + if err != nil { + return notFound(err) + } + m, err := tx.ModuleNamed(target) + if err != nil || m.Generation != generation || current.Status != domain.Pending || current.Revision != inst.Revision { + return domain.NotNow("installation attempt %s changed before acknowledgement", inst.ID) + } + current.Status = domain.Active + inst = current + return tx.PutInstallation(current) + }) + return inst, err +} + +// Uninstall disables an installation and its principal at once: every +// capability sealed to it is refused, its role grants are cleared, and the +// data its principal owns stays owned. +func (s *Service) Uninstall(ctx context.Context, installationID string) error { + return s.store.Write(ctx, func(tx Tx) error { + inst, err := tx.Installation(installationID) + if err != nil { + return notFound(err) + } + return s.revoke(tx, inst) + }) +} + +func (s *Service) revoke(tx Tx, inst domain.Installation) error { + inst.Status = domain.Revoked + inst.Revision++ + if err := tx.PutInstallation(inst); err != nil { + return err + } + return tx.DeleteRoleGrants(inst.ID) +} + +// Installations answers an organization's installations, for its administrator. +func (s *Service) Installations(ctx context.Context, caller Person, org string) ([]domain.Installation, error) { + var out []domain.Installation + err := s.store.Read(ctx, func(tx Tx) error { + p, err := tx.Person(caller.ID) + if err != nil || p.Organization != org || !decide.IsAdministrator(p) { + return domain.Refuse("installation administration requires an administrator or owner of organization %q", org) + } + out, err = tx.Installations(org) + return err + }) + return out, err +} diff --git a/libraries/authority-core/go/authority/principal_test.go b/libraries/authority-core/go/authority/principal_test.go new file mode 100644 index 00000000..e7eb15a9 --- /dev/null +++ b/libraries/authority-core/go/authority/principal_test.go @@ -0,0 +1,219 @@ +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" +) + +var read, write, create, admin = []string{"read"}, []string{"write"}, []string{"create"}, []string{"admin"} + +func TestThePrincipalIsStableDistinctAndPerOrganization(t *testing.T) { + h := newHarness(t) + u := h.principal(acme, "a") + if u == h.inst(acme, "a").ID || u == h.principal(acme, "b") || u == h.principal(globex, "a") { + t.Fatal("a principal is shared with an installation id, another solution or another organization") + } + if err := h.svc.Uninstall(h.ctx, h.inst(acme, "a").ID); err != nil { + t.Fatal(err) + } + if again := h.install(acme, "a"); again.PrincipalID != u { + t.Fatalf("a reinstall made principal %s, want %s", again.PrincipalID, u) + } +} + +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") + h.grant("a", "installer", "ines") + h.grant("a", "viewer", "maya") + grants, err := h.svc.ListRoleGrants(h.ctx, h.person("alex"), acme, h.inst(acme, "a").ID) + if err != nil || slices.IndexFunc(grants, func(g domain.RoleGrant) bool { return g.PersonID == "maya" }) < 0 || len(grants) != 2 { + t.Fatalf("grants %v %v: a repeated grant is one row", grants, err) + } + ines := h.person("ines") + if _, err := h.svc.GrantRole(h.ctx, ines, authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, "a").ID, Role: "viewer", PersonID: "dev"}); err != nil { + t.Fatalf("the installer grants: %v", err) + } + instA, instB, elsewhere := h.inst(acme, "a").ID, h.inst(acme, "b").ID, h.inst(globex, "a").ID + for name, c := range map[string]struct { + caller authority.Person + req authority.RoleRequest + code domain.Code + 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"}, + "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"}, + "an undeclared role": {h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "owner", PersonID: "dev"}, domain.NotFound, "declares no role"}, + "both subjects": {h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "viewer", PersonID: "dev", TeamID: "team"}, domain.Invalid, "exactly one"}, + "no subject": {h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "viewer"}, domain.Invalid, "exactly one"}, + "a person of another organization": {h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "viewer", PersonID: "omar"}, domain.Denied, "not a member"}, + "a team of another organization": {h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: instA, Role: "viewer", TeamID: "ops"}, domain.Denied, "not a team of"}, + } { + t.Run(name, func(t *testing.T) { + _, err := h.svc.GrantRole(h.ctx, c.caller, c.req) + refused(t, err, c.reason) + code(t, err, c.code) + }) + } + _, err = h.svc.ListRoleGrants(h.ctx, h.person("maya"), acme, instA) + refused(t, err, "may not") +} 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..c6d06f32 --- /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, 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 new file mode 100644 index 00000000..b1ca2893 --- /dev/null +++ b/libraries/authority-core/go/authority/roles.go @@ -0,0 +1,126 @@ +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" +) + +// RoleRequest names a role grant: the organization the caller acts in, the +// installation, the role and exactly one person or team. +type RoleRequest struct { + Organization, InstallationID, Role, PersonID, TeamID string +} + +func (r RoleRequest) grant() domain.RoleGrant { + return domain.RoleGrant{InstallationID: r.InstallationID, Role: r.Role, PersonID: r.PersonID, TeamID: r.TeamID} +} + +// decideRoleChange reads what decide.ChangeRole needs. +func decideRoleChange(tx Tx, caller Person, r RoleRequest, list bool) (domain.Installation, error) { + inst, err := tx.Installation(r.InstallationID) + if err != nil { + return inst, notFound(err) + } + c, err := tx.Person(caller.ID) + if err != nil { + c = domain.Person{ID: caller.ID, Organization: caller.Organization} + } + grants, err := tx.RoleGrants(inst.ID) + if err != nil { + return inst, err + } + solution, err := tx.ModuleNamed(inst.Target) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + return inst, err + } + change := decide.RoleChange{RequestOrganization: r.Organization, Installation: inst, Solution: solution, + Caller: c, CallerRoles: decide.HeldRoles(c, grants), List: list, Role: r.Role, PersonID: r.PersonID, TeamID: r.TeamID} + if r.PersonID != "" { + if p, err := tx.Person(r.PersonID); err == nil { + change.Subject = &p + } + } + if r.TeamID != "" { + people, err := tx.People(inst.Organization) + if err != nil { + return inst, err + } + change.TeamOfOrganization = slices.ContainsFunc(people, func(p domain.Person) bool { return slices.Contains(p.Teams, r.TeamID) }) + } + return inst, decide.ChangeRole(change) +} + +// GrantRole grants a person or a team a role: the access. It moves no +// revision, so every existing capability stays valid. +func (s *Service) GrantRole(ctx context.Context, caller Person, r RoleRequest) (domain.Installation, error) { + var inst domain.Installation + err := s.store.Write(ctx, func(tx Tx) error { + var err error + if inst, err = decideRoleChange(tx, caller, r, false); err != nil { + return err + } + return tx.PutRoleGrant(r.grant()) + }) + return inst, err +} + +// RevokeRole ends one grant. Its subject's capabilities through that +// installation are refused at once (their grant epoch on it moves): a +// person's, or every member's of a team. Nobody else's are touched. +func (s *Service) RevokeRole(ctx context.Context, caller Person, r RoleRequest) (domain.Installation, error) { + var inst domain.Installation + err := s.store.Write(ctx, func(tx Tx) error { + var err error + if inst, err = decideRoleChange(tx, caller, r, false); err != nil { + return err + } + if err := tx.DeleteRoleGrant(r.grant()); err != nil { + return err + } + subjects := []string{r.PersonID} + if r.TeamID != "" { + people, err := tx.People(inst.Organization) + if err != nil { + return err + } + subjects = nil + for _, p := range people { + if slices.Contains(p.Teams, r.TeamID) { + subjects = append(subjects, p.ID) + } + } + } + for _, person := range subjects { + if err := tx.BumpGrantEpoch(person, inst.ID); err != nil { + return err + } + } + return nil + }) + return inst, err +} + +// ListRoleGrants answers who holds which role on an installation. +func (s *Service) ListRoleGrants(ctx context.Context, caller Person, organization, installationID string) ([]domain.RoleGrant, error) { + var out []domain.RoleGrant + err := s.store.Read(ctx, func(tx Tx) error { + if _, err := decideRoleChange(tx, caller, RoleRequest{Organization: organization, InstallationID: installationID}, true); err != nil { + return err + } + var err error + out, err = tx.RoleGrants(installationID) + return err + }) + return out, err +} + +// PersonChanged is what a host calls once it changed a person's identity (a +// team left, the organization left): every capability the person holds is +// refused at once. +func (s *Service) PersonChanged(ctx context.Context, person string) error { + return s.store.Write(ctx, func(tx Tx) error { return tx.BumpPersonEpoch(person) }) +} diff --git a/libraries/authority-core/go/authority/service.go b/libraries/authority-core/go/authority/service.go new file mode 100644 index 00000000..9f1e57b9 --- /dev/null +++ b/libraries/authority-core/go/authority/service.go @@ -0,0 +1,317 @@ +// Package authority is the authority's service: every operation of the host +// interfaces as reads from a Store, a decision (package decide), writes, and +// a capability minted or verified with core's workcontext. Identity arrives +// authenticated (Person, Workload); the signing key stays with the host +// (Config.Signer, Config.Keys); transport stays out. +package authority + +import ( + "context" + "crypto" + "crypto/ed25519" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "github.com/google/uuid" +) + +// KeySource answers the public keys a capability is verified with, by key id. +type KeySource interface { + PublicKeys(ctx context.Context) (map[string]ed25519.PublicKey, error) +} + +// Config is what the host injects: the issuer name, the signer and its key +// id (the core never holds a key), the verification keys, the clock and the +// id sources, replay and approval-grant state. +type Config struct { + Issuer, KeyID string + Signer crypto.Signer + Keys KeySource + // Clock is now; nil means time.Now. + Clock func() time.Time + // NewID names tasks and delegations; nil means a random UUID. + NewID func() string + // InstallationID and PrincipalID name an organization's installation of a + // target and the principal it is; nil means the stable UUIDs below, so a + // reinstall keeps both and its data is never orphaned. + InstallationID, PrincipalID func(organization, target string) string + // MaxTTL clamps every capability; zero means 15 minutes. + MaxTTL time.Duration + // Replay consumes single-use capabilities; nil means in memory. + Replay workcontext.ReplayStore + // Grants answers approval grants; nil refuses every grant capability. + Grants workcontext.GrantSource +} + +var ( + installationNamespace = uuid.MustParse("6f1c2b0e-3a52-5d7c-9a1e-0c0de0a07b1a") + principalNamespace = uuid.MustParse("3c9b6f1e-8d2a-5b47-a0e1-50c1a7e0b1d2") +) + +// StableInstallationID is the default installation id: a UUID named by +// organization and target. +func StableInstallationID(org, target string) string { + return uuid.NewSHA1(installationNamespace, []byte(org+"\x00"+target)).String() +} + +// StablePrincipalID is the default solution principal: a UUID named by +// organization and target under a namespace of its own, so it is never the +// installation id and never shared across organizations. +func StablePrincipalID(org, target string) string { + return uuid.NewSHA1(principalNamespace, []byte(org+"\x00"+target)).String() +} + +// Service is the authority over one store. +type Service struct { + store Store + cfg Config + authority *workcontext.Authority + src sources +} + +// New builds the service. +func New(store Store, cfg Config) (*Service, error) { + if store == nil || cfg.Signer == nil || cfg.Keys == nil || cfg.Issuer == "" || cfg.KeyID == "" { + return nil, errors.New("authority: a store, an issuer, a key id, a signer and a key source are required") + } + if cfg.Clock == nil { + cfg.Clock = time.Now + } + if cfg.NewID == nil { + cfg.NewID = uuid.NewString + } + if cfg.InstallationID == nil { + cfg.InstallationID = StableInstallationID + } + if cfg.PrincipalID == nil { + cfg.PrincipalID = StablePrincipalID + } + if cfg.MaxTTL <= 0 { + cfg.MaxTTL = 15 * time.Minute + } + if cfg.Replay == nil { + replay := workcontext.NewMemoryReplayStore() + replay.Now = cfg.Clock + cfg.Replay = replay + } + s := &Service{store: store, cfg: cfg} + s.src = sources{s: s} + s.authority = &workcontext.Authority{Issuer: cfg.Issuer, KeyID: cfg.KeyID, Signer: cfg.Signer, Revisions: s.src, Seals: s.src, MaxTTL: cfg.MaxTTL, Now: cfg.Clock} + return s, nil +} + +// Seals and Revisions answer core's verifier and minter from the store. +func (s *Service) Seals() workcontext.SealSource { return s.src } +func (s *Service) Revisions() workcontext.RevisionSource { return s.src } + +// Person is an authenticated person: who the session is, at which organization. +type Person struct { + ID, Organization, SessionID string + // Teams is the session's snapshot, kept as attribution; authority reads + // the person's teams live. + Teams []string +} + +// Workload is an attested module workload. +type Workload struct { + Module string +} + +func (s *Service) verifier(ctx context.Context, audience string) (*workcontext.Verifier, error) { + keys, err := s.cfg.Keys.PublicKeys(ctx) + if err != nil { + return nil, err + } + grants := s.cfg.Grants + if grants == nil { + grants = noGrants{} + } + return &workcontext.Verifier{Issuer: s.cfg.Issuer, Audience: audience, Keys: keys, Revisions: s.src, Replay: s.cfg.Replay, Seals: s.src, Grants: grants, Now: s.cfg.Clock}, nil +} + +// Verify checks a capability for the audience the caller is. +func (s *Service) Verify(ctx context.Context, token, audience string) (*workcontext.Verified, error) { + v, err := s.verifier(ctx, audience) + if err != nil { + return nil, err + } + return v.Verify(ctx, token) +} + +type noGrants struct{} + +func (noGrants) Grant(_ context.Context, id string) (*workcontext.Grant, error) { + return nil, domain.Missing("grant %q: this authority issues no approval grants", id) +} + +// sources is the seal, epoch, binding and revision state core checks, read +// from the store. A seal's installation revision folds in what invalidates a +// capability sealed to it: the installation's own revision, the owner's +// grant epoch on it, and the installed solution's owner-aware revision. +type sources struct{ s *Service } + +func (src sources) AuthorizationRevision(ctx context.Context, tenant string) (uint64, error) { + var rev uint64 + err := src.s.store.Read(ctx, func(tx Tx) (err error) { + rev, err = tx.AuthorizationRevision(tenant) + return err + }) + return rev, notFound(err) +} + +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 + }) + return seal, err +} + +// 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 { + if principal == inst.PrincipalID && principal != "" { + return true + } + if _, err := tx.Module(principal); err == nil { + return true + } + if p, err := tx.Person(principal); err == nil { + return p.Organization == inst.Organization + } + source, err := tx.InstallationByPrincipal(principal) + return err == nil && source.Status == domain.Active && source.Organization == inst.Organization +} + +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 { + epoch = 1 + return nil + } + return domain.Missing("principal %q", principal) + }) + return epoch, err +} + +// 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. +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 { + parts := strings.SplitN(id, "/", 3) + if len(parts) != 3 { + return workcontext.ErrNoBinding + } + m, err := tx.ModuleNamed(parts[1]) + if err != nil { + return workcontext.ErrNoBinding + } + b, ok := m.Bindings[parts[2]] + if !ok { + return workcontext.ErrNoBinding + } + inst, err := installationOf(tx, parts[0], m.Name) + if err != nil { + return workcontext.ErrNoBinding + } + aware, err := tx.OwnerAwareRevision(b.Audience) + if err != nil { + return err + } + out = workcontext.OperationBinding{ID: id, PrincipalID: m.Principal, InstallationID: inst.ID, + Revision: m.Generation, Incarnation: aware + 1, Revoked: inst.Status == domain.Revoked} + return nil + }) + return out, err +} + +// installationOf is an organization's installation of target. +func installationOf(tx Tx, org, target string) (domain.Installation, error) { + all, err := tx.Installations(org) + if err != nil { + return domain.Installation{}, err + } + for _, inst := range all { + if inst.Target == target { + return inst, nil + } + } + return domain.Installation{}, fmt.Errorf("installation of %s at %s: %w", target, org, domain.ErrNotFound) +} + +func notFound(err error) error { + if errors.Is(err, domain.ErrNotFound) { + return domain.Missing("%v", err) + } + 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 new file mode 100644 index 00000000..c1bb6cf5 --- /dev/null +++ b/libraries/authority-core/go/authority/store.go @@ -0,0 +1,62 @@ +package authority + +import ( + "context" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" +) + +// Store is where a host keeps the authority's state. Read runs fn over one +// consistent snapshot; Write runs fn as one transaction, which the adapter +// retries on a serialization conflict, so fn must be free of other effects. +// A missing record is reported wrapping domain.ErrNotFound. +type Store interface { + Read(ctx context.Context, fn func(Tx) error) error + Write(ctx context.Context, fn func(Tx) error) error +} + +// Tx is one transaction's view. Identity (organizations, people, teams and +// organization roles) is the host's: the authority only reads it. +type Tx interface { + Organizations() ([]domain.Organization, error) + Organization(id string) (domain.Organization, error) + Person(id string) (domain.Person, error) + People(organization string) ([]domain.Person, error) + OrgRole(organization, name string) (domain.OrgRole, error) + + // Module answers by principal, ModuleNamed by name; Modules lists every one. + Module(principal string) (domain.Module, error) + ModuleNamed(name string) (domain.Module, error) + Modules() ([]domain.Module, error) + PutModule(domain.Module) error + DeleteModule(name string) error + + Installation(id string) (domain.Installation, error) + InstallationByPrincipal(principal string) (domain.Installation, error) + // Installations lists an organization's, or every one when it is "". + Installations(organization string) ([]domain.Installation, error) + PutInstallation(domain.Installation) error + + RoleGrants(installationID string) ([]domain.RoleGrant, error) + PutRoleGrant(domain.RoleGrant) error // idempotent + DeleteRoleGrant(domain.RoleGrant) error // idempotent + DeleteRoleGrants(installationID string) error // an uninstall clears them + + // AuthorizationRevision is an organization's, from 1; a bump invalidates + // every capability minted at it. + AuthorizationRevision(organization string) (uint64, error) + BumpAuthorizationRevision(organization string) error + // PersonEpoch moves when a person leaves a team or the organization. + PersonEpoch(person string) (uint64, error) + BumpPersonEpoch(person string) error + // GrantEpoch moves when a person loses a role on one installation. + GrantEpoch(person, installationID string) (uint64, error) + BumpGrantEpoch(person, installationID string) error + // OwnerAwareRevision moves when an audience withdraws owner-aware checking. + OwnerAwareRevision(audience string) (uint64, error) + BumpOwnerAwareRevision(audience string) error + + Delegation(id string) (domain.Delegation, error) + Delegations() ([]domain.Delegation, error) + PutDelegation(domain.Delegation) 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..4afc70ad --- /dev/null +++ b/libraries/authority-core/go/authority/transfer.go @@ -0,0 +1,125 @@ +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 + } + t.Writes = scope.HoldsAction(decide.RoleScopes(solution, target, decide.HeldRoles(p, grants)), 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/go.mod b/libraries/authority-core/go/go.mod index fd6750f7..ca7f09cc 100644 --- a/libraries/authority-core/go/go.mod +++ b/libraries/authority-core/go/go.mod @@ -5,11 +5,20 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/resources/go v0.0.0 github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 + github.com/google/uuid v1.6.0 google.golang.org/protobuf v1.36.12 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/go/protovalidate v1.4.0 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect ) diff --git a/libraries/authority-core/go/go.sum b/libraries/authority-core/go/go.sum index ab8be702..21734506 100644 --- a/libraries/authority-core/go/go.sum +++ b/libraries/authority-core/go/go.sum @@ -1,9 +1,33 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/libraries/authority-core/go/memstore/memstore.go b/libraries/authority-core/go/memstore/memstore.go new file mode 100644 index 00000000..26a2cd09 --- /dev/null +++ b/libraries/authority-core/go/memstore/memstore.go @@ -0,0 +1,336 @@ +// Package memstore is an in-memory authority.Store: one lock, and a deep copy +// of every record handed in or out, so a transaction's snapshot never changes +// under a reader. It backs the core's tests and the local authority. +package memstore + +import ( + "context" + "fmt" + "slices" + "sort" + "sync" + + "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" + "google.golang.org/protobuf/proto" +) + +// Store is the in-memory store. +type Store struct { + mu sync.RWMutex + orgs map[string]domain.Organization + people map[string]domain.Person + roles map[string]domain.OrgRole // organization/name + modules map[string]domain.Module // name + 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 +} + +// New is an empty store. +func New() *Store { + return &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{}, + } +} + +var _ authority.Store = (*Store)(nil) + +// Read runs fn under the read lock. +func (s *Store) Read(_ context.Context, fn func(authority.Tx) error) error { + s.mu.RLock() + defer s.mu.RUnlock() + return fn(tx{s: s}) +} + +// Write runs fn under the write lock. A failed fn leaves its writes applied +// up to the failure; the core writes only after its decisions succeed. +func (s *Store) Write(_ context.Context, fn func(authority.Tx) error) error { + s.mu.Lock() + defer s.mu.Unlock() + return fn(tx{s: s, write: true}) +} + +// PutOrganization, PutPerson, DeletePerson and PutOrgRole seed and change +// identity, the host's own data. +func (s *Store) PutOrganization(o domain.Organization) { + s.mu.Lock() + defer s.mu.Unlock() + s.orgs[o.ID] = o +} + +// PutPerson records a person. +func (s *Store) PutPerson(p domain.Person) { + s.mu.Lock() + defer s.mu.Unlock() + s.people[p.ID] = clonePerson(p) +} + +// DeletePerson removes a person. +func (s *Store) DeletePerson(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.people, id) +} + +// PutOrgRole records an organization role. +func (s *Store) PutOrgRole(r domain.OrgRole) { + s.mu.Lock() + defer s.mu.Unlock() + s.roles[r.Organization+"/"+r.Name] = r +} + +type tx struct { + s *Store + write bool +} + +func missing(what, id string) error { return fmt.Errorf("%s %q: %w", what, id, domain.ErrNotFound) } + +func (t tx) mustWrite() { + if !t.write { + panic("memstore: a write inside Read") + } +} + +func (t tx) Organizations() ([]domain.Organization, error) { + var out []domain.Organization + for _, o := range t.s.orgs { + out = append(out, o) + } + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out, nil +} + +func (t tx) Organization(id string) (domain.Organization, error) { + o, ok := t.s.orgs[id] + if !ok { + return o, missing("organization", id) + } + return o, nil +} + +func (t tx) Person(id string) (domain.Person, error) { + p, ok := t.s.people[id] + if !ok { + return p, missing("person", id) + } + return clonePerson(p), nil +} + +func (t tx) People(org string) ([]domain.Person, error) { + var out []domain.Person + for _, p := range t.s.people { + if p.Organization == org { + out = append(out, clonePerson(p)) + } + } + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out, nil +} + +func (t tx) OrgRole(org, name string) (domain.OrgRole, error) { + r, ok := t.s.roles[org+"/"+name] + if !ok { + return r, missing("role", org+"/"+name) + } + return r, nil +} + +func (t tx) Module(principal string) (domain.Module, error) { + for _, m := range t.s.modules { + if m.Principal == principal { + return cloneModule(m), nil + } + } + return domain.Module{}, missing("audience", principal) +} + +func (t tx) ModuleNamed(name string) (domain.Module, error) { + m, ok := t.s.modules[name] + if !ok { + return m, missing("module", name) + } + return cloneModule(m), nil +} + +func (t tx) Modules() ([]domain.Module, error) { + var out []domain.Module + for _, m := range t.s.modules { + out = append(out, cloneModule(m)) + } + sort.Slice(out, func(a, b int) bool { return out[a].Name < out[b].Name }) + return out, nil +} + +func (t tx) PutModule(m domain.Module) error { + t.mustWrite() + t.s.modules[m.Name] = cloneModule(m) + return nil +} + +func (t tx) DeleteModule(name string) error { + t.mustWrite() + delete(t.s.modules, name) + return nil +} + +func (t tx) Installation(id string) (domain.Installation, error) { + i, ok := t.s.installations[id] + if !ok { + return i, missing("installation", id) + } + return i, nil +} + +func (t tx) InstallationByPrincipal(principal string) (domain.Installation, error) { + for _, i := range t.s.installations { + if principal != "" && i.PrincipalID == principal { + return i, nil + } + } + return domain.Installation{}, missing("principal", principal) +} + +func (t tx) Installations(org string) ([]domain.Installation, error) { + var out []domain.Installation + for _, i := range t.s.installations { + if org == "" || i.Organization == org { + out = append(out, i) + } + } + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out, nil +} + +func (t tx) PutInstallation(i domain.Installation) error { + t.mustWrite() + t.s.installations[i.ID] = i + return nil +} + +func (t tx) RoleGrants(id string) ([]domain.RoleGrant, error) { + return slices.Clone(t.s.grants[id]), nil +} + +func (t tx) PutRoleGrant(g domain.RoleGrant) error { + t.mustWrite() + if !slices.Contains(t.s.grants[g.InstallationID], g) { + t.s.grants[g.InstallationID] = append(slices.Clone(t.s.grants[g.InstallationID]), g) + } + return nil +} + +func (t tx) DeleteRoleGrant(g domain.RoleGrant) error { + t.mustWrite() + t.s.grants[g.InstallationID] = slices.DeleteFunc(slices.Clone(t.s.grants[g.InstallationID]), func(h domain.RoleGrant) bool { return h == g }) + return nil +} + +func (t tx) DeleteRoleGrants(id string) error { + t.mustWrite() + delete(t.s.grants, id) + return nil +} + +func (t tx) AuthorizationRevision(org string) (uint64, error) { + if _, ok := t.s.orgs[org]; !ok { + return 0, missing("organization", org) + } + return t.s.revisions[org] + 1, nil +} + +func (t tx) BumpAuthorizationRevision(org string) error { + t.mustWrite() + t.s.revisions[org]++ + return nil +} + +func (t tx) PersonEpoch(p string) (uint64, error) { return t.s.personEpochs[p] + 1, nil } +func (t tx) BumpPersonEpoch(p string) error { + t.mustWrite() + t.s.personEpochs[p]++ + return nil +} + +func (t tx) GrantEpoch(p, inst string) (uint64, error) { return t.s.grantEpochs[p+"\x00"+inst], nil } +func (t tx) BumpGrantEpoch(p, inst string) error { + t.mustWrite() + t.s.grantEpochs[p+"\x00"+inst]++ + return nil +} + +func (t tx) OwnerAwareRevision(audience string) (uint64, error) { return t.s.ownerAware[audience], nil } +func (t tx) BumpOwnerAwareRevision(audience string) error { + t.mustWrite() + t.s.ownerAware[audience]++ + return nil +} + +func (t tx) Delegation(id string) (domain.Delegation, error) { + d, ok := t.s.delegations[id] + if !ok { + return d, missing("delegation", id) + } + d.Scopes = cloneScopes(d.Scopes) + return d, nil +} + +func (t tx) Delegations() ([]domain.Delegation, error) { + var out []domain.Delegation + for _, d := range t.s.delegations { + d.Scopes = cloneScopes(d.Scopes) + out = append(out, d) + } + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out, nil +} + +func (t tx) PutDelegation(d domain.Delegation) error { + t.mustWrite() + d.Scopes = cloneScopes(d.Scopes) + t.s.delegations[d.ID] = d + return nil +} + +func clonePerson(p domain.Person) domain.Person { + p.Roles, p.Teams = slices.Clone(p.Roles), slices.Clone(p.Teams) + return p +} + +func cloneScopes(in []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { + if in == nil { + return nil + } + out := make([]*basev0.WorkScopeV1, len(in)) + for i, s := range in { + out[i] = proto.CloneOf(s) + } + return out +} + +func cloneModule(m domain.Module) domain.Module { + m.ResourceKinds = cloneScopes(m.ResourceKinds) + bindings := make(map[string]domain.Binding, len(m.Bindings)) + for id, b := range m.Bindings { + b.InvokeScopes, b.HeadlessScopes, b.LookupScopes = cloneScopes(b.InvokeScopes), cloneScopes(b.HeadlessScopes), cloneScopes(b.LookupScopes) + bindings[id] = b + } + m.Bindings = bindings + if m.Roles != nil { + roles := make(map[string][]*basev0.WorkScopeV1, len(m.Roles)) + for name, scopes := range m.Roles { + roles[name] = cloneScopes(scopes) + } + m.Roles = roles + } + return m +} From 6c2c2113a2da92012a50dc3a6b5d3ec7a5f71d81 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:55:20 -0400 Subject: [PATCH 2/6] test: a child holds only the owner's grants the binding shares (r122) A parent at page holding page.entry, collection and profiles, hopping through a binding that covers collection alone: the child's effective scopes, as issued and as checked, are collection read/write only, and asking for profiles through that binding is refused as a widening. Co-Authored-By: Claude Opus 5.5 --- .../go/authority/child_intersection_test.go | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 libraries/authority-core/go/authority/child_intersection_test.go 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..43049e6a --- /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 +} From ab7d80e24b6c821f4cbf781c39b4735092043222 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:27:58 -0400 Subject: [PATCH 3/6] fix: r132 on the core: a revoke outlives uninstall and retired roles, foreign answers echo the request, readers gated in CI - m1: ChangeRole returns for a revoke right after the exactly-one-subject check, before the active and role-declared checks; tested in decide and in the service with a reinstall that reuses the id and a role retired then declared again. - m2: RoleChange carries the requested id, which ForeignInstallation echoes; the service resolves the installation by (organization, id) before reading anything else, so a foreign and an absent installation do the same work and answer the same text. - m4: clients.yml runs the authority client's Python and TypeScript tests, the other two readers of the shared claims.json. - m5: hop requires the authority client at f688c549, main's merge commit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/clients.yml | 52 +++++++++++++++++++ .../authority-core/go/authority/r124_test.go | 34 ++++++++++++ .../authority-core/go/authority/roles.go | 8 +-- libraries/authority-core/go/decide/decide.go | 33 ++++++++---- .../authority-core/go/decide/decide_test.go | 21 +++++--- libraries/hop/go/go.mod | 2 +- libraries/hop/go/go.sum | 4 +- 7 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/clients.yml diff --git a/.github/workflows/clients.yml b/.github/workflows/clients.yml new file mode 100644 index 00000000..49ca2a34 --- /dev/null +++ b/.github/workflows/clients.yml @@ -0,0 +1,52 @@ +name: clients + +# The authority client's Python and TypeScript readers are tested on each pull +# request and on main, beside the Go modules (go.yml): the three read the +# shared testdata/claims.json, so a reader drifting in one language fails here. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: authority-client python + runs-on: ubuntu-latest + defaults: + run: + working-directory: libraries/authority-client/python + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: install + run: python -m pip install . pytest + - name: pytest + run: python -m pytest -q + + typescript: + name: authority-client typescript + runs-on: ubuntu-latest + defaults: + run: + working-directory: libraries/authority-client/typescript + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: libraries/authority-client/typescript/package-lock.json + - name: install + run: npm ci + - name: npm test + run: npm test diff --git a/libraries/authority-core/go/authority/r124_test.go b/libraries/authority-core/go/authority/r124_test.go index 151a1a57..e2232d39 100644 --- a/libraries/authority-core/go/authority/r124_test.go +++ b/libraries/authority-core/go/authority/r124_test.go @@ -33,3 +33,37 @@ func TestAForeignAndAnAbsentInstallationAnswerAlike(t *testing.T) { t.Fatalf("foreign %q, absent %q", errForeign, errAbsent) } } + +// 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, 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/roles.go b/libraries/authority-core/go/authority/roles.go index cb07ad18..27bfeda6 100644 --- a/libraries/authority-core/go/authority/roles.go +++ b/libraries/authority-core/go/authority/roles.go @@ -21,9 +21,11 @@ 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. inst, err := tx.Installation(r.InstallationID) - if errors.Is(err, domain.ErrNotFound) { - // An absent installation answers as another organization's does. + if errors.Is(err, domain.ErrNotFound) || (err == nil && inst.Organization != r.Organization) { return inst, decide.ForeignInstallation(r.InstallationID, r.Organization) } if err != nil { @@ -41,7 +43,7 @@ func decideRoleChange(tx Tx, caller Person, r RoleRequest, list, revoke bool) (d if err != nil && !errors.Is(err, domain.ErrNotFound) { return inst, err } - change := decide.RoleChange{RequestOrganization: r.Organization, Installation: inst, Solution: solution, + change := decide.RoleChange{RequestOrganization: r.Organization, RequestedID: r.InstallationID, Installation: inst, Solution: solution, Caller: c, CallerRoles: decide.HeldRoles(c, inst, grants), List: list, Revoke: revoke, Role: r.Role, PersonID: r.PersonID, TeamID: r.TeamID} if r.PersonID != "" { if p, err := tx.Person(r.PersonID); err == nil { diff --git a/libraries/authority-core/go/decide/decide.go b/libraries/authority-core/go/decide/decide.go index 5dc8d8ee..2d4dc71f 100644 --- a/libraries/authority-core/go/decide/decide.go +++ b/libraries/authority-core/go/decide/decide.go @@ -157,10 +157,11 @@ func ScopesFor(b domain.Binding, operation, def string) (Scopes, error) { // RoleChange is one GrantRole, RevokeRole or ListRoleGrants request with the // state it is decided against. type RoleChange struct { - // RequestOrganization is the organization the request names. - RequestOrganization string - Installation domain.Installation - Solution domain.Module + // RequestOrganization and RequestedID are the organization and the + // installation id the request names, echoed by a refusal as asked. + RequestOrganization, RequestedID string + Installation domain.Installation + Solution domain.Module // Caller is the authenticated person; CallerRoles what they hold on the installation. Caller domain.Person CallerRoles []string @@ -177,12 +178,13 @@ type RoleChange struct { // ChangeRole decides a role change, in order: the organization the request // names must be the installation's, before the caller's authority is read; // then the caller must administer that organization or hold the installer -// role on that installation; then the installation must be active, the role -// declared, exactly one subject named, and that subject of the organization. +// role on that installation. A revoke then needs only exactly one subject; a +// grant needs the installation active, the role declared, exactly one subject +// named, and that subject of the organization. func ChangeRole(r RoleChange) error { inst := r.Installation if r.RequestOrganization != inst.Organization { - return ForeignInstallation(inst.ID, r.RequestOrganization) + return ForeignInstallation(r.RequestedID, r.RequestOrganization) } if r.Caller.Organization != inst.Organization || (!IsAdministrator(r.Caller) && !slices.Contains(r.CallerRoles, domain.InstallerRole)) { if r.List { @@ -190,6 +192,15 @@ func ChangeRole(r RoleChange) error { } return domain.Refuse("%s may not grant roles on %s: an administrator of %s or its installer grants", r.Caller.ID, inst.ID, inst.Organization) } + if r.Revoke { + // A grant stays revocable whatever happened since: an installation + // uninstalled (a reinstall reuses its id), a role no longer declared + // (a later generation may declare it again), a person who left. + if (r.PersonID == "") == (r.TeamID == "") { + return domain.Malformed("a role grant names exactly one person or team") + } + return nil + } if inst.Status != domain.Active { return domain.NotNow("installation %s is %s: %s is not installed", inst.ID, inst.Status, inst.Target) } @@ -202,9 +213,6 @@ func ChangeRole(r RoleChange) error { if (r.PersonID == "") == (r.TeamID == "") { return domain.Malformed("a role grant names exactly one person or team") } - if r.Revoke { - return nil - } if r.PersonID != "" && (r.Subject == nil || r.Subject.Organization != inst.Organization) { return domain.Refuse("%q is not a member of %s", r.PersonID, inst.Organization) } @@ -216,7 +224,10 @@ func ChangeRole(r RoleChange) error { // ForeignInstallation is the one answer for an installation the request's // organization does not have, whether another organization has it or nobody -// does: a person learns nothing of another organization's installations. +// does: a person learns nothing of another organization's installations. It +// echoes the id as requested. A host resolves the installation by +// (organization, id), or answers this before any other lookup, so the two +// cases also do the same work. func ForeignInstallation(id, organization string) error { return domain.Refuse("installation %s belongs to another organization than %q", id, organization) } diff --git a/libraries/authority-core/go/decide/decide_test.go b/libraries/authority-core/go/decide/decide_test.go index 34668925..4444c7b8 100644 --- a/libraries/authority-core/go/decide/decide_test.go +++ b/libraries/authority-core/go/decide/decide_test.go @@ -113,7 +113,7 @@ func TestWhoMayChangeARoleInOrder(t *testing.T) { member := domain.Person{ID: "dev", Organization: "acme"} omar := domain.Person{ID: "omar", Organization: "globex"} base := func() decide.RoleChange { - return decide.RoleChange{RequestOrganization: "acme", Installation: inst, Solution: solution, Caller: admin, Role: "viewer", PersonID: "dev", Subject: &member} + return decide.RoleChange{RequestOrganization: "acme", RequestedID: inst.ID, Installation: inst, Solution: solution, Caller: admin, Role: "viewer", PersonID: "dev", Subject: &member} } if err := decide.ChangeRole(base()); err != nil { t.Fatal(err) @@ -148,11 +148,14 @@ func TestWhoMayChangeARoleInOrder(t *testing.T) { refused(t, decide.ChangeRole(r), c.code, c.reason) }) } - // A grant stays revocable after its person left or its team is gone. + // A grant stays revocable after its person left, its team is gone, its + // installation was uninstalled, or its role is no longer declared. for name, mutate := range map[string]func(*decide.RoleChange){ - "a person who left": func(r *decide.RoleChange) { r.Subject = nil }, - "a person now elsewhere": func(r *decide.RoleChange) { r.Subject = &omar }, - "a team no member carries": func(r *decide.RoleChange) { r.PersonID, r.TeamID = "", "gone" }, + "an uninstalled installation": func(r *decide.RoleChange) { r.Installation.Status = domain.Revoked }, + "a role no longer declared": func(r *decide.RoleChange) { r.Role = "retired" }, + "a person who left": func(r *decide.RoleChange) { r.Subject = nil }, + "a person now elsewhere": func(r *decide.RoleChange) { r.Subject = &omar }, + "a team no member carries": func(r *decide.RoleChange) { r.PersonID, r.TeamID = "", "gone" }, } { t.Run("revoking "+name, func(t *testing.T) { r := base() @@ -163,10 +166,12 @@ func TestWhoMayChangeARoleInOrder(t *testing.T) { } }) } - // Another organization's installation and none at all answer alike. + refused(t, func() error { r := base(); r.Revoke, r.TeamID = true, "t"; return decide.ChangeRole(r) }(), domain.Invalid, "exactly one") + // Another organization's installation and none at all answer alike, with + // the id as requested. other := base() - other.RequestOrganization = "globex" - if got, absent := decide.ChangeRole(other).Error(), decide.ForeignInstallation(inst.ID, "globex").Error(); got != absent { + other.RequestOrganization, other.RequestedID = "globex", "I" + if got, absent := decide.ChangeRole(other).Error(), decide.ForeignInstallation("I", "globex").Error(); got != absent { t.Fatalf("a foreign installation answers %q, an absent one %q", got, absent) } } diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index d2cb3555..7ff6aec2 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index a1f68829..e9f8f31a 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 h1:awoC/NqbOw77H5QLpqx/hnMYpouCb4fnMQXHQow0USM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 2046cb18602fd2f061aa04222e7168a60d90bc45 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:40:31 -0400 Subject: [PATCH 4/6] wip: r137 fixes before the split --- .../go/authority/harness_test.go | 5 +- .../authority-core/go/authority/lifecycle.go | 35 ++- .../go/authority/principal_test.go | 2 +- .../authority-core/go/authority/r124_test.go | 2 +- .../go/authority/revocation_test.go | 2 +- .../authority-core/go/authority/service.go | 24 ++- .../go/authority/sources_test.go | 203 ++++++++++++++++++ .../authority-core/go/authority/store.go | 13 +- .../authority-core/go/memstore/memstore.go | 40 +++- modules/authority/services/local/code/go.mod | 2 +- modules/authority/services/local/code/go.sum | 6 +- 11 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 libraries/authority-core/go/authority/sources_test.go diff --git a/libraries/authority-core/go/authority/harness_test.go b/libraries/authority-core/go/authority/harness_test.go index 98c5bbed..d63e1f44 100644 --- a/libraries/authority-core/go/authority/harness_test.go +++ b/libraries/authority-core/go/authority/harness_test.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "errors" "fmt" + "github.com/codefly-dev/core/workcontext" "strings" "testing" "time" @@ -70,7 +71,9 @@ func newHarness(t *testing.T) *harness { } h := &harness{t: t, store: memstore.New(), now: time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC), ctx: context.Background()} n := 0 - h.svc, err = authority.New(h.store, authority.Config{Issuer: "https://authority.test", KeyID: "k1", Signer: private, Keys: keys{public}, + 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) }}) if err != nil { t.Fatal(err) diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go index a2265b3e..ffdf0368 100644 --- a/libraries/authority-core/go/authority/lifecycle.go +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -32,6 +32,15 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e if other, err := tx.Module(m.Principal); err == nil && other.Name != m.Name { return domain.Refuse("principal %q already belongs to %s", m.Principal, other.Name) } + if exists && previous.Principal != m.Principal { + return domain.Refuse("%s is principal %q for life; a delivery cannot rename it %q", m.Name, previous.Principal, m.Principal) + } + if _, err := tx.Person(m.Principal); err == nil { + return domain.Refuse("principal %q names a person; a module's principal is its own", m.Principal) + } + if _, err := tx.InstallationByPrincipal(m.Principal); err == nil { + return domain.Refuse("principal %q names an installed solution's principal; a module's principal is its own", m.Principal) + } m.ResourceKinds = scope.Normalize(m.ResourceKinds) if exists && decide.Narrowed(previous, m) { all, err := tx.Installations("") @@ -187,24 +196,42 @@ func (s *Service) Install(ctx context.Context, caller *Person, org, target, agen } // Uninstall disables an installation and its principal at once: every -// capability sealed to it is refused, its role grants are cleared, and the -// data its principal owns stays owned. -func (s *Service) Uninstall(ctx context.Context, installationID string) error { +// capability sealed to it is refused, every capability of its organization +// minted before is refused (a hop through the solution sealed elsewhere +// included), its role grants are cleared, and the data its principal owns +// stays owned. A caller, when named, must administer organization; a host's +// own setup names none. Another organization's installation answers as an +// absent one. +func (s *Service) Uninstall(ctx context.Context, caller *Person, organization, installationID string) error { return s.store.Write(ctx, func(tx Tx) error { inst, err := tx.Installation(installationID) + if errors.Is(err, domain.ErrNotFound) || (err == nil && inst.Organization != organization) { + return decide.ForeignInstallation(installationID, organization) + } if err != nil { - return notFound(err) + return err + } + if caller != nil { + p, err := tx.Person(caller.ID) + if err != nil || p.Organization != organization || !decide.IsAdministrator(p) { + return domain.Refuse("installation administration requires an administrator or owner of organization %q", organization) + } } return s.revoke(tx, inst) }) } +// revoke disables inst: its revision and its organization's authorization +// revision both move, and its grants are cleared. func (s *Service) revoke(tx Tx, inst domain.Installation) error { inst.Status = domain.Revoked inst.Revision++ if err := tx.PutInstallation(inst); err != nil { return err } + if err := tx.BumpAuthorizationRevision(inst.Organization); err != nil { + return err + } return tx.DeleteRoleGrants(inst.ID) } diff --git a/libraries/authority-core/go/authority/principal_test.go b/libraries/authority-core/go/authority/principal_test.go index e7eb15a9..87dd652c 100644 --- a/libraries/authority-core/go/authority/principal_test.go +++ b/libraries/authority-core/go/authority/principal_test.go @@ -17,7 +17,7 @@ func TestThePrincipalIsStableDistinctAndPerOrganization(t *testing.T) { if u == h.inst(acme, "a").ID || u == h.principal(acme, "b") || u == h.principal(globex, "a") { t.Fatal("a principal is shared with an installation id, another solution or another organization") } - if err := h.svc.Uninstall(h.ctx, h.inst(acme, "a").ID); err != nil { + if err := h.svc.Uninstall(h.ctx, nil, acme, h.inst(acme, "a").ID); err != nil { t.Fatal(err) } if again := h.install(acme, "a"); again.PrincipalID != u { diff --git a/libraries/authority-core/go/authority/r124_test.go b/libraries/authority-core/go/authority/r124_test.go index e2232d39..5012ba55 100644 --- a/libraries/authority-core/go/authority/r124_test.go +++ b/libraries/authority-core/go/authority/r124_test.go @@ -41,7 +41,7 @@ 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, id); err != nil { + if err := h.svc.Uninstall(h.ctx, nil, acme, id); err != nil { t.Fatal(err) } h.revoke("a", "viewer", "maya", "") diff --git a/libraries/authority-core/go/authority/revocation_test.go b/libraries/authority-core/go/authority/revocation_test.go index c6d06f32..8e4ef190 100644 --- a/libraries/authority-core/go/authority/revocation_test.go +++ b/libraries/authority-core/go/authority/revocation_test.go @@ -75,7 +75,7 @@ func TestUninstallDisablesThePrincipalAndClearsGrants(t *testing.T) { 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, inst); err != nil { + if err := h.svc.Uninstall(h.ctx, nil, acme, inst); err != nil { t.Fatal(err) } for _, c := range []authority.Issued{before, agent} { diff --git a/libraries/authority-core/go/authority/service.go b/libraries/authority-core/go/authority/service.go index 9f1e57b9..8d13fc28 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -43,7 +43,8 @@ type Config struct { InstallationID, PrincipalID func(organization, target string) string // MaxTTL clamps every capability; zero means 15 minutes. MaxTTL time.Duration - // Replay consumes single-use capabilities; nil means in memory. + // Replay consumes single-use capabilities. It is required: a deployed + // host shares it across replicas, and nothing defaults to memory. Replay workcontext.ReplayStore // Grants answers approval grants; nil refuses every grant capability. Grants workcontext.GrantSource @@ -77,8 +78,8 @@ type Service struct { // New builds the service. func New(store Store, cfg Config) (*Service, error) { - if store == nil || cfg.Signer == nil || cfg.Keys == nil || cfg.Issuer == "" || cfg.KeyID == "" { - return nil, errors.New("authority: a store, an issuer, a key id, a signer and a key source are required") + if store == nil || cfg.Signer == nil || cfg.Keys == nil || cfg.Issuer == "" || cfg.KeyID == "" || cfg.Replay == nil { + return nil, errors.New("authority: a store, an issuer, a key id, a signer, a key source and a replay store are required") } if cfg.Clock == nil { cfg.Clock = time.Now @@ -95,11 +96,6 @@ func New(store Store, cfg Config) (*Service, error) { if cfg.MaxTTL <= 0 { cfg.MaxTTL = 15 * time.Minute } - if cfg.Replay == nil { - replay := workcontext.NewMemoryReplayStore() - replay.Now = cfg.Clock - cfg.Replay = replay - } s := &Service{store: store, cfg: cfg} s.src = sources{s: s} s.authority = &workcontext.Authority{Issuer: cfg.Issuer, KeyID: cfg.KeyID, Signer: cfg.Signer, Revisions: s.src, Seals: s.src, MaxTTL: cfg.MaxTTL, Now: cfg.Clock} @@ -154,6 +150,13 @@ func (noGrants) Grant(_ context.Context, id string) (*workcontext.Grant, error) // from the store. A seal's installation revision folds in what invalidates a // 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. type sources struct{ s *Service } func (src sources) AuthorizationRevision(ctx context.Context, tenant string) (uint64, error) { @@ -221,7 +224,10 @@ func (src sources) PrincipalEpoch(ctx context.Context, principal string) (uint64 return perr } if inst, err := tx.InstallationByPrincipal(principal); err == nil && inst.Status == domain.Active { - epoch = 1 + // 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) diff --git a/libraries/authority-core/go/authority/sources_test.go b/libraries/authority-core/go/authority/sources_test.go new file mode 100644 index 00000000..62eecd77 --- /dev/null +++ b/libraries/authority-core/go/authority/sources_test.go @@ -0,0 +1,203 @@ +package authority_test + +import ( + "context" + "crypto/ed25519" + "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" + "github.com/codefly-dev/core/workcontext" +) + +// B2: an installation principal's epoch grows across uninstall and +// reinstall, so a capability sealed at an earlier incarnation never verifies +// again, whatever role the reinstall holds. +func TestAReinstallNeverRepeatsThePrincipalsEpoch(t *testing.T) { + h := newHarness(t) + u := h.principal(acme, "a") + before, err := h.svc.Seals().PrincipalEpoch(h.ctx, u) + 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) + } + if _, err := h.svc.Seals().PrincipalEpoch(h.ctx, u); err == nil { + t.Fatal("an uninstalled principal has an epoch") + } + admin := h.admin(acme) + if _, err := h.svc.Install(h.ctx, &admin, acme, "a", "viewer", admin.ID, nil); err != nil { + t.Fatal(err) + } + after, err := h.svc.Seals().PrincipalEpoch(h.ctx, u) + if err != nil || after <= before { + t.Fatalf("the reinstalled principal's epoch is %d (%v), was %d", after, err, before) + } +} + +// M1: an uninstall moves its organization's authorization revision, so a +// hop through the solution sealed to another installation ends too. +func TestAnUninstallMovesItsOrganizationsRevision(t *testing.T) { + h := newHarness(t) + rev := func(org string) uint64 { + r, err := h.svc.Revisions().AuthorizationRevision(h.ctx, org) + if err != nil { + t.Fatal(err) + } + return r + } + acmeBefore, globexBefore := rev(acme), rev(globex) + if err := h.svc.Uninstall(h.ctx, nil, acme, h.inst(acme, "a").ID); err != nil { + t.Fatal(err) + } + if rev(acme) <= acmeBefore || rev(globex) != globexBefore { + t.Fatalf("acme %d→%d, globex %d→%d", acmeBefore, rev(acme), globexBefore, rev(globex)) + } +} + +// M2: an uninstall is an administrator's act in the installation's own +// organization; another organization's installation answers as an absent +// one, and nothing changes. +func TestAnUninstallIsTheOrganizationsAdministrators(t *testing.T) { + h := newHarness(t) + id := h.inst(acme, "a").ID + maya, zoe := h.person("maya"), h.person("zoe") + refused(t, h.svc.Uninstall(h.ctx, &maya, acme, id), "administrator") + foreign := h.svc.Uninstall(h.ctx, &zoe, globex, id) + absent := h.svc.Uninstall(h.ctx, &zoe, globex, "no-such-installation") + code(t, foreign, domain.Denied) + if want := "installation " + id + " belongs to another organization than \"globex\""; foreign.Error() != want { + t.Fatalf("foreign %q", foreign) + } + if want := "installation no-such-installation belongs to another organization than \"globex\""; absent.Error() != want { + t.Fatalf("absent %q", absent) + } + if inst, err := h.svc.Installations(h.ctx, h.admin(acme), acme); err != nil || !activeIn(inst, id) { + t.Fatalf("a refused uninstall changed %s: %v", id, err) + } + alex := h.admin(acme) + if err := h.svc.Uninstall(h.ctx, &alex, acme, id); err != nil { + t.Fatal(err) + } +} + +func activeIn(all []domain.Installation, id string) bool { + for _, inst := range all { + if inst.ID == id { + return inst.Status == domain.Active + } + } + return false +} + +// m3: the seal's revision only grows across everything that invalidates a +// capability sealed to an installation: a revoke, a narrowing, an +// owner-aware withdrawal, an uninstall and a reinstall. +func TestTheSealRevisionNeverRepeats(t *testing.T) { + h := newHarness(t) + id := h.inst(acme, "a").ID + last := uint64(0) + step := func(what string) { + t.Helper() + seal, err := h.svc.Seals().Seal(h.ctx, "maya", id) + if err != nil { + t.Fatalf("%s: %v", what, err) + } + if seal.InstallationRevision <= last { + t.Fatalf("%s: the seal revision is %d, was %d", what, seal.InstallationRevision, last) + } + last = seal.InstallationRevision + } + step("installed") + h.grant("a", "viewer", "maya") + h.revoke("a", "viewer", "maya", "") + step("revoked") + narrowed := solution("a", true) + narrowed.Generation = 2 + narrowed.Roles["viewer"] = narrowed.Roles["viewer"][:1] + if _, err := h.svc.ApplyModule(h.ctx, narrowed); err != nil { + t.Fatal(err) + } + step("narrowed") + blind := solution("a", false) + blind.Generation = 3 + blind.Roles["viewer"] = narrowed.Roles["viewer"] + if _, err := h.svc.ApplyModule(h.ctx, blind); err != nil { + t.Fatal(err) + } + step("owner-aware withdrawn") + if err := h.svc.Uninstall(h.ctx, nil, acme, id); err != nil { + t.Fatal(err) + } + if _, err := h.svc.Seals().Seal(h.ctx, "maya", id); !errors.Is(err, workcontext.ErrNoSeal) { + t.Fatalf("an uninstalled installation seals: %v", err) + } + h.install(acme, "a") + step("reinstalled") +} + +// m6: a module's principal is its own: never a person's, never an installed +// solution's, and never renamed by a later delivery. +func TestAModulePrincipalIsItsOwnForLife(t *testing.T) { + h := newHarness(t) + for name, m := range map[string]domain.Module{ + "a person's id": {Name: "impostor", Principal: "maya", Kind: domain.KindModule, Generation: 1}, + "a solution's principal": {Name: "impostor", Principal: h.principal(acme, "a"), Kind: domain.KindModule, Generation: 1}, + "a renamed principal": {Name: "store", Principal: "store-2", Kind: domain.KindModule, Generation: 2}, + } { + t.Run(name, func(t *testing.T) { + _, err := h.svc.ApplyModule(h.ctx, m) + code(t, err, domain.Denied) + }) + } +} + +// m7: the replay store is required; nothing defaults to memory. +func TestTheReplayStoreIsRequired(t *testing.T) { + _, err := authority.New(memstore.New(), authority.Config{Issuer: "i", KeyID: "k", Signer: signerForTest(t), Keys: keys{}}) + if err == nil { + t.Fatal("a service started with no replay store") + } +} + +// m2: a write whose fn fails leaves nothing behind. +func TestAFailedWriteLeavesNothing(t *testing.T) { + store := memstore.New() + store.PutOrganization(domain.Organization{ID: acme}) + boom := errors.New("boom") + err := store.Write(h0, func(tx authority.Tx) error { + if err := tx.PutInstallation(domain.Installation{ID: "i", Organization: acme, Target: "a", Status: domain.Active}); err != nil { + return err + } + if err := tx.BumpAuthorizationRevision(acme); err != nil { + return err + } + return boom + }) + if !errors.Is(err, boom) { + t.Fatal(err) + } + _ = store.Read(h0, func(tx authority.Tx) error { + if _, err := tx.Installation("i"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("the failed write's installation survived: %v", err) + } + if rev, _ := tx.AuthorizationRevision(acme); rev != 1 { + t.Fatalf("the failed write's revision survived: %d", rev) + } + return nil + }) +} + +var h0 = context.Background() + +func signerForTest(t *testing.T) ed25519.PrivateKey { + t.Helper() + _, private, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + return private +} diff --git a/libraries/authority-core/go/authority/store.go b/libraries/authority-core/go/authority/store.go index c1bb6cf5..4d6710d0 100644 --- a/libraries/authority-core/go/authority/store.go +++ b/libraries/authority-core/go/authority/store.go @@ -2,19 +2,26 @@ package authority import ( "context" + "errors" "github.com/codefly-dev/authority/libraries/authority-core/go/domain" ) // Store is where a host keeps the authority's state. Read runs fn over one -// consistent snapshot; Write runs fn as one transaction, which the adapter -// retries on a serialization conflict, so fn must be free of other effects. -// A missing record is reported wrapping domain.ErrNotFound. +// consistent snapshot. Write runs fn as one transaction: all of fn's writes +// or none, so an fn that fails leaves nothing behind. An adapter that meets a +// serialization conflict retries fn (it must be free of other effects) and, +// when it gives up, reports an error wrapping ErrConflict. A missing record +// is reported wrapping domain.ErrNotFound. type Store interface { Read(ctx context.Context, fn func(Tx) error) error Write(ctx context.Context, fn func(Tx) error) error } +// ErrConflict is what a Store reports when a transaction could not commit +// against a concurrent one and was not retried to success. +var ErrConflict = errors.New("authority: the transaction conflicted with another") + // Tx is one transaction's view. Identity (organizations, people, teams and // organization roles) is the host's: the authority only reads it. type Tx interface { diff --git a/libraries/authority-core/go/memstore/memstore.go b/libraries/authority-core/go/memstore/memstore.go index 26a2cd09..b395050b 100644 --- a/libraries/authority-core/go/memstore/memstore.go +++ b/libraries/authority-core/go/memstore/memstore.go @@ -6,6 +6,7 @@ package memstore import ( "context" "fmt" + "maps" "slices" "sort" "sync" @@ -51,12 +52,45 @@ func (s *Store) Read(_ context.Context, fn func(authority.Tx) error) error { return fn(tx{s: s}) } -// Write runs fn under the write lock. A failed fn leaves its writes applied -// up to the failure; the core writes only after its decisions succeed. +// 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 { s.mu.Lock() defer s.mu.Unlock() - return fn(tx{s: s, write: true}) + before := s.snapshot() + if err := fn(tx{s: s, write: true}); err != nil { + s.restore(before) + return err + } + return nil +} + +// state is every map the authority writes; identity (organizations, people, +// roles) is the host's and changes only through the seeding methods. +type state struct { + 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 +} + +func (s *Store) snapshot() state { + grants := make(map[string][]domain.RoleGrant, len(s.grants)) + for id, g := range s.grants { + grants[id] = slices.Clone(g) + } + 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)} +} + +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 } // PutOrganization, PutPerson, DeletePerson and PutOrgRole seed and change diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 6fbf3899..abc042d4 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -30,7 +30,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index 7b7eb465..47b4a8b3 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -36,10 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 h1:awoC/NqbOw77H5QLpqx/hnMYpouCb4fnMQXHQow0USM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d h1:qHuS/Er1SXh+I/RT8ytyxtJf2V57lhMftCcx6Wx9rco= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026032520-f688c5493b4d/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From ce4f39cad654f1cf2d85816d23adcd0d4f976465 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:41:15 -0400 Subject: [PATCH 5/6] wip: source-level revocation tests --- .../go/authority/sources_test.go | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/libraries/authority-core/go/authority/sources_test.go b/libraries/authority-core/go/authority/sources_test.go index 62eecd77..adaba67d 100644 --- a/libraries/authority-core/go/authority/sources_test.go +++ b/libraries/authority-core/go/authority/sources_test.go @@ -201,3 +201,93 @@ func signerForTest(t *testing.T) ed25519.PrivateKey { } return private } + +func (h *harness) seal(person, target string) uint64 { + h.t.Helper() + s, err := h.svc.Seals().Seal(h.ctx, person, h.inst(acme, target).ID) + if err != nil { + h.t.Fatal(err) + } + return s.InstallationRevision +} + +// A grant moves no seal; a revoke moves only its subject's seal on that one +// installation: the same person's on another installation, and another +// person's on this one, stay as they were. +func TestARevokeMovesOnlyItsSubjectsSealOnThatInstallation(t *testing.T) { + h := newHarness(t) + mayaA, mayaB, remiA := h.seal("maya", "a"), h.seal("maya", "b"), h.seal("remi", "a") + h.grant("a", "viewer", "maya") + h.grant("b", "viewer", "maya") + h.grant("a", "viewer", "remi") + if h.seal("maya", "a") != mayaA || h.seal("maya", "b") != mayaB || h.seal("remi", "a") != remiA { + t.Fatal("a grant moved a seal") + } + h.revoke("a", "viewer", "maya", "") + if h.seal("maya", "a") <= mayaA { + t.Fatal("the revoke left maya's seal on a") + } + if h.seal("maya", "b") != mayaB || h.seal("remi", "a") != remiA { + t.Fatal("the revoke moved a seal it does not name") + } +} + +// A team's revoke moves every member's seal on the installation; a person +// leaving a team or the organization moves their person epoch. +func TestATeamRevokeAndALeaveMoveTheirMembers(t *testing.T) { + h := newHarness(t) + tam, tal := h.seal("tam", "a"), h.seal("tal", "a") + h.grantTeam("a", "viewer", "team") + h.revoke("a", "viewer", "", "team") + if h.seal("tam", "a") <= tam || h.seal("tal", "a") <= tal { + t.Fatal("the team's revoke left a member's seal") + } + before, err := h.svc.Seals().PrincipalEpoch(h.ctx, "tam") + if err != nil { + t.Fatal(err) + } + if err := h.svc.PersonChanged(h.ctx, "tam"); err != nil { + t.Fatal(err) + } + if after, _ := h.svc.Seals().PrincipalEpoch(h.ctx, "tam"); after <= before { + t.Fatalf("leaving left tam's epoch at %d", after) + } +} + +// A revoke on an uninstalled installation and on a retired role succeeds, +// and neither a reinstall nor the role declared again brings the grant back. +func TestARevokedGrantStaysGone(t *testing.T) { + h := newHarness(t) + id := h.inst(acme, "a").ID + grants := func() int { + g, err := h.svc.ListRoleGrants(h.ctx, h.admin(acme), acme, id) + if err != nil { + t.Fatal(err) + } + return len(g) + } + 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 || grants() != 0 { + t.Fatalf("the reinstall %s carries %d grant(s)", again.ID, grants()) + } + 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) + } + if grants() != 0 { + t.Fatal("the re-declared role revived the revoked grant") + } +} From 07d63b58bfda8f841968e7c62b5d5e326d58fa4c Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:42:36 -0400 Subject: [PATCH 6/6] fix: r137 on #19: monotonic principal epochs, uninstall ends its organization's capabilities, the store is transactional; issuance moves to its own PR - B2: an installation principal's epoch is its revision, which grows on every install and uninstall, so a reinstall never repeats an epoch an earlier incarnation's capabilities were sealed at. - M1: Uninstall moves its organization's authorization revision, ending a hop through the solution sealed to another installation. - M2: Uninstall(ctx, caller, organization, id) checks an administrator as Install does, and answers another organization's installation as an absent one, changing nothing. - B3: modules/authority/services/local/code is tidied for hop's client bump. - m2: memstore.Write undoes every write of a failing fn; the Store contract says so and names ErrConflict for an adapter that gives up retrying. - m3: the seal revision's invariant is documented and tested across revoke, narrowing, owner-aware withdrawal, uninstall and reinstall; a module keeps its principal for life (ApplyModule refuses a rename). - m6: ApplyModule refuses a principal naming a person or an installed solution's principal. - m7: the replay store is required; nothing defaults to memory. - Split (m5): issuance, check, transfer and delegation, with B1, m1 and m4, move to the next PR. This one keeps the store, the in-memory store, the seal sources, roles and lifecycle, with source-level revocation tests: a revoke moves only its subject's seal on that installation, a team revoke its members', a leave the person's epoch, and a revoked grant stays gone across reinstall and a re-declared role. Each new test was run against its fix reverted and failed. Co-Authored-By: Claude Opus 5.5 --- .../authority-core/go/authority/check.go | 163 ----- .../go/authority/child_intersection_test.go | 59 -- .../authority-core/go/authority/delegation.go | 197 ------ .../go/authority/harness_test.go | 62 -- .../authority-core/go/authority/issue.go | 623 ------------------ .../go/authority/principal_test.go | 153 ----- .../authority-core/go/authority/r124_test.go | 45 -- .../go/authority/revocation_test.go | 154 ----- .../authority-core/go/authority/service.go | 30 - .../authority-core/go/authority/transfer.go | 129 ---- 10 files changed, 1615 deletions(-) delete mode 100644 libraries/authority-core/go/authority/check.go delete mode 100644 libraries/authority-core/go/authority/child_intersection_test.go delete mode 100644 libraries/authority-core/go/authority/delegation.go delete mode 100644 libraries/authority-core/go/authority/issue.go delete mode 100644 libraries/authority-core/go/authority/revocation_test.go delete mode 100644 libraries/authority-core/go/authority/transfer.go diff --git a/libraries/authority-core/go/authority/check.go b/libraries/authority-core/go/authority/check.go deleted file mode 100644 index 4dd98de1..00000000 --- a/libraries/authority-core/go/authority/check.go +++ /dev/null @@ -1,163 +0,0 @@ -package authority - -import ( - "context" - "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) { - if decoded, err := workcontext.Decode(token); err == nil { - wc := decoded.Context() - if scope.HasOwner(effectiveOf(wc)) && wc.GetOperationBinding().GetBindingId() != "" { - if current, err := s.src.OperationBinding(ctx, wc.GetOperationBinding().GetBindingId()); err == nil && current.Incarnation != wc.GetOperationBinding().GetIncarnation() { - return nil, 0, domain.Refuse("issued before %s last withdrew owner-aware checking; only a fresh capability carries owner-selected scopes", audience) - } - } - } - verified, err := s.Verify(ctx, token, 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 deleted file mode 100644 index fa5a60dd..00000000 --- a/libraries/authority-core/go/authority/child_intersection_test.go +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index c318b099..00000000 --- a/libraries/authority-core/go/authority/delegation.go +++ /dev/null @@ -1,197 +0,0 @@ -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 revision uint64 - 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 revision, err = tx.AuthorizationRevision(d.Organization); err != nil { - return err - } - 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 - return nil - }) - if err != nil { - return Issued{}, err - } - root, err := s.startAtRevision(ctx, revision, 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 d63e1f44..831be59f 100644 --- a/libraries/authority-core/go/authority/harness_test.go +++ b/libraries/authority-core/go/authority/harness_test.go @@ -167,44 +167,6 @@ 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() @@ -220,28 +182,4 @@ 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 deleted file mode 100644 index f32cd2c8..00000000 --- a/libraries/authority-core/go/authority/issue.go +++ /dev/null @@ -1,623 +0,0 @@ -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 -} - -// startAtRevision mints at the revision the decision read: a concurrent grant -// change must not turn old scopes into fresh authority. -func (s *Service) startAtRevision(ctx context.Context, revision uint64, in workcontext.StartInput) (Issued, error) { - token, signed, err := s.authority.Start(ctx, in) - if err != nil { - return Issued{}, err - } - if signed.GetAuthorizationRevision() != revision { - return Issued{}, domain.Refuse("permissions changed during issuance") - } - 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 revision uint64 - 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) - } - if revision, err = tx.AuthorizationRevision(p.Organization); err != nil { - return err - } - 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 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.startAtRevision(ctx, revision, 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 revision uint64 - 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) - } - if inst, err = tx.Installation(installationID); err != nil { - return notFound(err) - } - if r.Organization != "" && r.Organization != inst.Organization { - return domain.Refuse("installation %s belongs to organization %q, not %q", inst.ID, inst.Organization, r.Organization) - } - 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 revision, err = tx.AuthorizationRevision(inst.Organization); 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.startAtRevision(ctx, revision, 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()) - } - 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 - return nil - }) - if err != nil { - return Issued{}, err - } - return s.derive(ctx, verified, am, audience, r, scopes, authorityScopes, sealTo) -} - -// 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) (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") - } - 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/principal_test.go b/libraries/authority-core/go/authority/principal_test.go index 87dd652c..e0fa53f7 100644 --- a/libraries/authority-core/go/authority/principal_test.go +++ b/libraries/authority-core/go/authority/principal_test.go @@ -6,7 +6,6 @@ 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"} @@ -25,158 +24,6 @@ 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") diff --git a/libraries/authority-core/go/authority/r124_test.go b/libraries/authority-core/go/authority/r124_test.go index 5012ba55..1010a6a5 100644 --- a/libraries/authority-core/go/authority/r124_test.go +++ b/libraries/authority-core/go/authority/r124_test.go @@ -7,18 +7,6 @@ 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) { @@ -34,36 +22,3 @@ 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/revocation_test.go b/libraries/authority-core/go/authority/revocation_test.go deleted file mode 100644 index 8e4ef190..00000000 --- a/libraries/authority-core/go/authority/revocation_test.go +++ /dev/null @@ -1,154 +0,0 @@ -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/service.go b/libraries/authority-core/go/authority/service.go index 8d13fc28..c35b504b 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -11,7 +11,6 @@ import ( "crypto/ed25519" "errors" "fmt" - "slices" "strings" "time" @@ -290,34 +289,5 @@ 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/transfer.go b/libraries/authority-core/go/authority/transfer.go deleted file mode 100644 index 2724a733..00000000 --- a/libraries/authority-core/go/authority/transfer.go +++ /dev/null @@ -1,129 +0,0 @@ -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 -}