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/harness_test.go b/libraries/authority-core/go/authority/harness_test.go new file mode 100644 index 00000000..831be59f --- /dev/null +++ b/libraries/authority-core/go/authority/harness_test.go @@ -0,0 +1,185 @@ +package authority_test + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "github.com/codefly-dev/core/workcontext" + "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 + 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) + } + 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) + } +} + +// 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) + } +} + +var _ = errors.New diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go new file mode 100644 index 00000000..ffdf0368 --- /dev/null +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -0,0 +1,250 @@ +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) + } + 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("") + 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, 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 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) +} + +// 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..e0fa53f7 --- /dev/null +++ b/libraries/authority-core/go/authority/principal_test.go @@ -0,0 +1,66 @@ +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" +) + +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, nil, acme, 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 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/r124_test.go b/libraries/authority-core/go/authority/r124_test.go new file mode 100644 index 00000000..1010a6a5 --- /dev/null +++ b/libraries/authority-core/go/authority/r124_test.go @@ -0,0 +1,24 @@ +package authority_test + +import ( + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" +) + +// Another organization's installation and an installation nobody has answer +// a role change with the same words. +func TestAForeignAndAnAbsentInstallationAnswerAlike(t *testing.T) { + h := newHarness(t) + foreign := h.inst(globex, "a").ID + _, errForeign := h.svc.GrantRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: foreign, Role: "viewer", PersonID: "maya"}) + _, errAbsent := h.svc.GrantRole(h.ctx, h.person("alex"), authority.RoleRequest{Organization: acme, InstallationID: foreign[:len(foreign)-1] + "0", Role: "viewer", PersonID: "maya"}) + code(t, errForeign, domain.Denied) + code(t, errAbsent, domain.Denied) + strip := func(err error, id string) string { return err.Error()[len("installation "+id):] } + if strip(errForeign, foreign) != strip(errAbsent, foreign[:len(foreign)-1]+"0") { + t.Fatalf("foreign %q, absent %q", errForeign, errAbsent) + } +} + diff --git a/libraries/authority-core/go/authority/roles.go b/libraries/authority-core/go/authority/roles.go new file mode 100644 index 00000000..27bfeda6 --- /dev/null +++ b/libraries/authority-core/go/authority/roles.go @@ -0,0 +1,132 @@ +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, 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) || (err == nil && inst.Organization != r.Organization) { + return inst, decide.ForeignInstallation(r.InstallationID, r.Organization) + } + if err != nil { + return inst, 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, 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 { + 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, 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, true); 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, false); 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..c35b504b --- /dev/null +++ b/libraries/authority-core/go/authority/service.go @@ -0,0 +1,293 @@ +// 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" + "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. 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 +} + +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 == "" || 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 + } + 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 + } + 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. +// +// 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) { + 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 { + // 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) + }) + 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 +} + +// Scopes is a scope list. +type Scopes = []*basev0.WorkScopeV1 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..adaba67d --- /dev/null +++ b/libraries/authority-core/go/authority/sources_test.go @@ -0,0 +1,293 @@ +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 +} + +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") + } +} diff --git a/libraries/authority-core/go/authority/store.go b/libraries/authority-core/go/authority/store.go new file mode 100644 index 00000000..4d6710d0 --- /dev/null +++ b/libraries/authority-core/go/authority/store.go @@ -0,0 +1,69 @@ +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: 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 { + 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/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/authority-core/go/go.mod b/libraries/authority-core/go/go.mod index 4bd0fc18..68a22f92 100644 --- a/libraries/authority-core/go/go.mod +++ b/libraries/authority-core/go/go.mod @@ -6,10 +6,19 @@ require ( github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 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 9d43f5b0..d1591e5d 100644 --- a/libraries/authority-core/go/go.sum +++ b/libraries/authority-core/go/go.sum @@ -1,5 +1,15 @@ 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/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 h1:PnhgA9wkib67bJnc5hkhkc80J2ch5uYiq+9/NiD/XFU= github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= @@ -8,6 +18,20 @@ github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YR 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..b395050b --- /dev/null +++ b/libraries/authority-core/go/memstore/memstore.go @@ -0,0 +1,370 @@ +// 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" + "maps" + "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, 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() + 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 +// 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 +} 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= 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=