From 4238b340b2ffd7025d7e97e0d826a7b3e8c7a8fb Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:51:11 -0400 Subject: [PATCH 1/5] test: the solution-principal target, specified before it is built An installed solution becomes a principal of its organisation with a stable UUID; its roles are permission levels on the data that principal owns; granting a role is the access; scopes select resources by owner, not by id lists; an existing resource joins only by a transfer the person administers through their own organisation grants. RunSolutionPrincipalTarget is the shared conformance target (not yet in Run). It first names every contract element the published descriptors lack, then specifies nine cases. The local authority runs it only under AUTHORITY_TARGET=solution-principal, where it fails today, naming the missing contract. Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/README.md | 26 ++ .../conformance/go/solution_principal.go | 374 ++++++++++++++++++ .../code/pkg/business/testkit/testkit.go | 2 +- .../solution_principal_target_test.go | 93 +++++ 4 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 libraries/conformance/go/solution_principal.go create mode 100644 modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index aa2680ee..a6af8870 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -67,3 +67,29 @@ Empty workload organizations are required refusals when the module has multiple active installing organizations or none, naming “must name the organization of the work item”. `WorkloadOrganizations` supplies these two fixtures. Successful single-organization inference remains a local convenience, not a shared promise. + +## Target: solution principal (not yet part of `Run`) + +`RunSolutionPrincipalTarget` specifies solution identity before any host +implements it. An installed solution is a principal of its organisation with a +stable UUID, reused by a reinstall; its declared roles are permission levels on +the data that principal owns; granting a person a role (`GrantRole`) is the +access; a capability's scopes select resources by owner (`resource_owner_id`) +rather than by id lists; an existing resource joins the principal only through +`AuthorizeTransfer`, which requires admin the person holds from their own +organisation grants. The suite first names every contract element the +published descriptors lack, then runs these cases: + +- install creates the solution principal and its roles; a reinstall keeps its UUID +- without a role on the solution a person is refused, naming the reason +- a viewer reads what the principal owns, and nothing another installation or a person owns +- a resource created through the solution is owned by its principal, the person its author (and the installation's agent creates for the same principal) +- an editor writes and a viewer cannot, each by name +- another installation's members never reach the principal's data +- a child never widens the owner or the actions it narrowed to +- an existing resource joins the principal only by a transfer its person administers through their own grants (an installer role alone and the agent are refused) +- uninstalling disables the principal at once and its data stays owned; a re-grant after reinstall restores access + +Run it against the local authority from `modules/authority/services/local/code` +with `AUTHORITY_TARGET=solution-principal go test ./plugins/registry_test -run SolutionPrincipal`. +It joins `Run` once a host implements it. diff --git a/libraries/conformance/go/solution_principal.go b/libraries/conformance/go/solution_principal.go new file mode 100644 index 00000000..3060bb11 --- /dev/null +++ b/libraries/conformance/go/solution_principal.go @@ -0,0 +1,374 @@ +package conformance + +import ( + "context" + "regexp" + "slices" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// SolutionPrincipalCase is the TARGET contract for solution identity; no host +// passes it yet. An installed solution is a principal of its organisation, with +// a stable UUID like a person's. Its declared roles are permission levels on +// the data that principal owns, and granting a person a role is the access. +// A capability carries owner-selected scopes: {kind, actions, resource owner}, +// never lists of ids, so a module admits a resource whose owner is the scope's. +// +// The suite delivers A, B (two solutions) and Store (the module serving Kind) +// at Generation, installs A and B through InstallationService, and grants every +// role itself. The host supplies people and one existing resource: +// - Admin administers Organization; it installs and grants. Its own +// organisation grants hold nothing on Kind. +// - Viewer, Editor and Member hold no role on A or B until the suite grants +// one; their organisation grants on Kind are irrelevant to the case. +// - Administrator holds admin on Existing, and Reader holds read on Existing, +// each from their own organisation grants at Store and on nothing else of +// Kind. Existing is a resource of Kind no solution principal owns. +// +// Uninstall is the host's uninstall path for an installation id (the interface +// has no uninstall operation). The host's install step must converge for the +// suite's solutions, so Install answers an active installation. +type SolutionPrincipalCase struct { + Organization string + A, B SolutionUnderTest + Store, StoreCredential, Kind string + Generation uint64 + Admin, Viewer, Editor, Member string + Administrator, Reader string + Existing string + Uninstall func(ctx context.Context, installationID string) error + SignManifest, SignAuthority func(document []byte) ([]byte, error) + DeliveryCredential func(module string) string + HostCoordinate, HostDomain string +} + +// SolutionUnderTest is one solution the suite delivers and installs. +type SolutionUnderTest struct { + Solution, Credential string +} + +// solutionPrincipalContract is what the target adds to the published contract. +// Its absence is the first, named failure of every case. +var solutionPrincipalContract = []struct{ message, element string }{ + {"host.installation.v1.Installation", "principal_id"}, + {"host.installation.v1.Installation", "roles"}, + {"host.installation.v1.InstallationService", "GrantRole"}, + {"host.installation.v1.GrantRoleRequest", "organization_id"}, + {"host.installation.v1.GrantRoleRequest", "installation_id"}, + {"host.installation.v1.GrantRoleRequest", "role"}, + {"host.installation.v1.GrantRoleRequest", "person_id"}, + {"host.installation.v1.GrantRoleRequest", "team_id"}, + {"host.authority.v1.WorkScopeV1", "resource_owner_id"}, + {"host.authority.v1.ResourceGrant", "owner"}, + {"host.authority.v1.AuthorityService", "AuthorizeTransfer"}, + {"host.authority.v1.AuthorizeTransferRequest", "token"}, + {"host.authority.v1.AuthorizeTransferRequest", "actor"}, + {"host.authority.v1.AuthorizeTransferRequest", "resource_kind"}, + {"host.authority.v1.AuthorizeTransferRequest", "resource_id"}, + {"host.authority.v1.AuthorizeTransferRequest", "to_principal_id"}, + {"host.authority.v1.AuthorizeTransferResponse", "principal_id"}, +} + +var canonicalUUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// RunSolutionPrincipalTarget drives the target over one connection. It is not +// part of Run until a host implements it. +func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f SolutionPrincipalCase) { + t.Helper() + c, err := newClient(conn, descriptors) + if err != nil { + t.Fatalf("conformance: %v", err) + } + if missing := c.missingContract(); len(missing) > 0 { + t.Fatalf("target: the published contract lacks %v", missing) + } + if f.Uninstall == nil || f.SignManifest == nil || f.SignAuthority == nil || f.DeliveryCredential == nil || f.Existing == "" || + f.Admin == "" || f.Viewer == "" || f.Editor == "" || f.Member == "" || f.Administrator == "" || f.Reader == "" { + t.Fatal("required conformance fixture missing: solution principal case") + } + ctx := context.Background() + const binding, self = "store", "self" + all := []string{f.Kind + ":admin", f.Kind + ":create", f.Kind + ":read", f.Kind + ":write"} + delivery := Fixture{SignManifest: f.SignManifest, SignAuthority: f.SignAuthority, DeliveryCredential: f.DeliveryCredential, HostCoordinate: f.HostCoordinate, HostDomain: f.HostDomain} + if _, err := c.deliver(ctx, delivery, f.Store, "module", f.Generation, []string{f.Kind}, + map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, []string{"admin", "create", "read", "write"}, nil)}}, 0, + solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read", f.Kind + ":write"}}}); err != nil { + t.Fatalf("deliver %s: %v", f.Store, err) + } + // Each role is a level on the principal's data: no role names an owner, + // since the owner is always the installation's own principal. + for _, s := range []SolutionUnderTest{f.A, f.B} { + own := s.Solution + ".note" + doc := map[string]any{ + "scope_ceilings": []any{scopeAsk(own, []string{"read", "write"}, nil)}, + "roles": []any{ + map[string]any{"name": "installer", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, []string{"admin", "create", "read", "write"}, nil)}}, + map[string]any{"name": "editor", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, []string{"create", "read", "write"}, nil)}}, + map[string]any{"name": "viewer", "scopes": []any{scopeAsk(own, []string{"read"}, nil), scopeAsk(f.Kind, []string{"read"}, nil)}}, + }, + } + if _, err := c.deliver(ctx, delivery, s.Solution, "solution", f.Generation, []string{own}, doc, 0, + solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": all}}); err != nil { + t.Fatalf("deliver %s: %v", s.Solution, err) + } + } + + sessions := map[string]protoreflect.Message{} + session := func(t *testing.T, token string) protoreflect.Message { + t.Helper() + if s, ok := sessions[token]; ok { + return s + } + s := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": token}) + sessions[token] = s + return s + } + personID := func(t *testing.T, token string) string { return str(session(t, token), "person_id") } + asPerson := func(t *testing.T, token string) context.Context { return as(ctx, session(t, token)) } + installed := map[string]protoreflect.Message{} + install := func(t *testing.T, s SolutionUnderTest) protoreflect.Message { + t.Helper() + out := c.must(t, asPerson(t, f.Admin), "host.installation.v1.InstallationService", "Install", map[string]any{ + "organization_id": f.Organization, "target": s.Solution, "role": "editor", "owner_person_id": personID(t, f.Admin)}) + if enumName(out, "status") != "STATUS_ACTIVE" { + t.Fatalf("%s is %s; the host's install step must converge for the suite's solutions", s.Solution, enumName(out, "status")) + } + installed[s.Solution] = out + return out + } + principal := func(s SolutionUnderTest) string { return str(installed[s.Solution], "principal_id") } + grant := func(t *testing.T, s SolutionUnderTest, role, token string) { + t.Helper() + c.must(t, asPerson(t, f.Admin), "host.installation.v1.InstallationService", "GrantRole", map[string]any{ + "organization_id": f.Organization, "installation_id": str(installed[s.Solution], "id"), "role": role, "person_id": personID(t, token)}) + } + issue := func(fields map[string]any) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "Issue", fields) + } + root := func(t *testing.T, token string, s SolutionUnderTest) (protoreflect.Message, error) { + return c.call(asPerson(t, token), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": s.Solution}) + } + // through is a person at s, then s's hop to Store asking scopes. + through := func(t *testing.T, token string, s SolutionUnderTest, scopes ...any) (protoreflect.Message, error) { + t.Helper() + r, err := root(t, token, s) + if err != nil { + return nil, err + } + return issue(map[string]any{"parent_token": str(r, "token"), "actor": map[string]any{"credential": s.Credential}, + "operation_binding_id": binding, "audience": f.Store, "scopes": scopes}) + } + hop := func(parent protoreflect.Message, scopes ...any) (protoreflect.Message, error) { + return issue(map[string]any{"parent_token": str(parent, "token"), "actor": map[string]any{"credential": f.StoreCredential}, + "operation_binding_id": self, "audience": f.Store, "scopes": scopes}) + } + agent := func(s SolutionUnderTest, scopes ...any) (protoreflect.Message, error) { + return issue(map[string]any{"installation": map[string]any{"installation_id": str(installed[s.Solution], "id")}, + "actor": map[string]any{"credential": s.Credential}, "audience": f.Store, "scopes": scopes}) + } + owned := func(actions []string, owner string) map[string]any { + ask := scopeAsk(f.Kind, actions, nil) + ask["resource_owner_id"] = owner + return ask + } + transfer := func(token, to string) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "AuthorizeTransfer", map[string]any{"token": token, + "actor": map[string]any{"credential": f.StoreCredential}, "resource_kind": f.Kind, "resource_id": f.Existing, "to_principal_id": to}) + } + // must(t)(call()) stops the case at an unexpected refusal. + must := func(t *testing.T) func(protoreflect.Message, error) protoreflect.Message { + return func(out protoreflect.Message, err error) protoreflect.Message { + t.Helper() + if err != nil { + t.Fatal(err) + } + return out + } + } + + t.Run("install creates the solution principal and its roles; a reinstall keeps its UUID", func(t *testing.T) { + a, b := install(t, f.A), install(t, f.B) + for _, inst := range []protoreflect.Message{a, b} { + id := str(inst, "principal_id") + if !canonicalUUID.MatchString(id) || id == str(inst, "id") || id == str(inst, "agent_principal_id") { + t.Fatalf("principal_id %q is not a principal UUID of its own", id) + } + if got := slices.Sorted(slices.Values(listStrings(inst, "roles"))); !slices.Equal(got, []string{"editor", "installer", "viewer"}) { + t.Fatalf("the installation's roles are %v, want the solution's declared roles", got) + } + } + if principal(f.A) == principal(f.B) { + t.Fatal("two installations share a principal") + } + before := principal(f.A) + if err := f.Uninstall(ctx, str(a, "id")); err != nil { + t.Fatal(err) + } + if install(t, f.A); principal(f.A) != before { + t.Fatalf("a reinstall made principal %s, want %s: its data would be orphaned", principal(f.A), before) + } + }) + t.Run("without a role on the solution a person is refused, naming the reason", func(t *testing.T) { + _, err := root(t, f.Viewer, f.A) + refused(t, err, codes.PermissionDenied, "holds no role") + }) + t.Run("a viewer reads what the principal owns, and nothing another installation or a person owns", func(t *testing.T) { + grant(t, f.A, "viewer", f.Viewer) + out := must(t)(through(t, f.Viewer, f.A)) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, principal(f.A)) + c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.Store}) + listed := c.must(t, ctx, "host.authority.v1.AuthorityService", "ListResources", map[string]any{"token": str(out, "token"), "audience": f.Store, "resource_kind": f.Kind, "action": "read"}) + grants := listMsgs(listed, "grants") + if len(grants) != 1 || str(grants[0], "owner") != principal(f.A) { + t.Fatalf("listed %v, want exactly the resources principal %s owns", grants, principal(f.A)) + } + for name, ask := range map[string]any{ + "another installation's data": owned([]string{"read"}, principal(f.B)), + "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), + "a resource it does not own": scopeAsk(f.Kind, []string{"read"}, []string{f.Existing}), + } { + _, err := through(t, f.Viewer, f.A, ask) + t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) + } + }) + t.Run("a resource created through the solution is owned by its principal, the person its author", func(t *testing.T) { + grant(t, f.A, "editor", f.Editor) + out := must(t)(through(t, f.Editor, f.A, owned([]string{"create"}, principal(f.A)))) + wc := msg(out, "context") + exactOwnedScope(t, wc, f.Kind, []string{"create"}, principal(f.A)) + if str(wc, "owner_principal_id") != personID(t, f.Editor) || str(wc, "owner_principal_kind") != "human" { + t.Fatalf("the author is %s (%s), want the person", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind")) + } + // The installation acting for itself creates for the same principal. + byAgent := must(t)(agent(f.A, owned([]string{"create"}, principal(f.A)))) + exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create"}, principal(f.A)) + if str(msg(byAgent, "context"), "owner_principal_kind") != "agent" { + t.Fatal("an installation acting for itself is its agent") + } + }) + t.Run("an editor writes and a viewer cannot, each by name", func(t *testing.T) { + out := must(t)(through(t, f.Editor, f.A, owned([]string{"write"}, principal(f.A)))) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"write"}, principal(f.A)) + _, err := through(t, f.Viewer, f.A, owned([]string{"write"}, principal(f.A))) + refused(t, err, codes.PermissionDenied, "widens") + }) + t.Run("another installation's members never reach the principal's data", func(t *testing.T) { + grant(t, f.B, "viewer", f.Member) + _, err := root(t, f.Member, f.A) + refused(t, err, codes.PermissionDenied, "holds no role") + out := must(t)(through(t, f.Member, f.B)) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, principal(f.B)) + _, err = through(t, f.Member, f.B, owned([]string{"read"}, principal(f.A))) + refused(t, err, codes.PermissionDenied, "widens") + }) + t.Run("a child never widens the owner or the actions it narrowed to", func(t *testing.T) { + narrowed := must(t)(through(t, f.Viewer, f.A, owned([]string{"read"}, principal(f.A)))) + next := must(t)(hop(narrowed)) + exactOwnedScope(t, msg(next, "context"), f.Kind, []string{"read"}, principal(f.A)) + for name, ask := range map[string]any{ + "another owner": owned([]string{"read"}, principal(f.B)), + "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), + } { + _, err := hop(narrowed, ask) + t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) + } + readOnly := must(t)(through(t, f.Editor, f.A, owned([]string{"read"}, principal(f.A)))) + _, err := hop(readOnly, owned([]string{"write"}, principal(f.A))) + refused(t, err, codes.PermissionDenied, "widens") + }) + t.Run("an existing resource joins the principal only by a transfer its person administers through their own grants", func(t *testing.T) { + grant(t, f.A, "viewer", f.Administrator) + grant(t, f.A, "viewer", f.Reader) + administers := must(t)(through(t, f.Administrator, f.A)) + out := must(t)(transfer(str(administers, "token"), principal(f.A))) + if str(out, "principal_id") != principal(f.A) { + t.Fatalf("transferred to %s, want %s", str(out, "principal_id"), principal(f.A)) + } + _, err := transfer(str(administers, "token"), principal(f.B)) + refused(t, err, codes.PermissionDenied, "issued through") + reads := must(t)(through(t, f.Reader, f.A)) + _, err = transfer(str(reads, "token"), principal(f.A)) + refused(t, err, codes.PermissionDenied, "own organisation grants") + // The installer's role administers the principal's data, not Existing. + installer := must(t)(through(t, f.Admin, f.A)) + _, err = transfer(str(installer, "token"), principal(f.A)) + refused(t, err, codes.PermissionDenied, "own organisation grants") + byAgent := must(t)(agent(f.A)) + _, err = transfer(str(byAgent, "token"), principal(f.A)) + refused(t, err, codes.PermissionDenied, "a person's act") + }) + t.Run("uninstalling disables the principal at once and its data stays owned; a re-grant after reinstall restores access", func(t *testing.T) { + before := must(t)(through(t, f.Viewer, f.A)) + u := principal(f.A) + if err := f.Uninstall(ctx, str(installed[f.A.Solution], "id")); err != nil { + t.Fatal(err) + } + _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(before, "token"), "audience": f.Store}) + refused(t, err, codes.PermissionDenied, "") + _, err = agent(f.A) + refused(t, err, codes.PermissionDenied, "") + if install(t, f.A); principal(f.A) != u { + t.Fatalf("reinstalled as %s, want %s", principal(f.A), u) + } + _, err = root(t, f.Viewer, f.A) + refused(t, err, codes.PermissionDenied, "holds no role") + grant(t, f.A, "viewer", f.Viewer) + out := must(t)(through(t, f.Viewer, f.A)) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, u) + }) +} + +// missingContract names each target element the published descriptors lack. +func (c *client) missingContract() []string { + var missing []string + for _, want := range solutionPrincipalContract { + d, err := c.files.FindDescriptorByName(protoreflect.FullName(want.message)) + if err != nil { + if !slices.Contains(missing, want.message) { + missing = append(missing, want.message) + } + continue + } + switch d := d.(type) { + case protoreflect.MessageDescriptor: + if d.Fields().ByName(protoreflect.Name(want.element)) == nil { + missing = append(missing, want.message+"."+want.element) + } + case protoreflect.ServiceDescriptor: + if d.Methods().ByName(protoreflect.Name(want.element)) == nil { + missing = append(missing, want.message+"/"+want.element) + } + } + } + return missing +} + +// exactOwnedScope requires the effective scope to be exactly kind and actions +// on resources owned by owner: no id list, no other kind. +func exactOwnedScope(t *testing.T, wc protoreflect.Message, kind string, actions []string, owner string) { + t.Helper() + exactEffectiveScope(t, wc, kind, actions, nil) + scopes := listMsgs(wc, "authority_scopes") + if actors := listMsgs(wc, "actor_chain"); len(actors) > 0 { + scopes = listMsgs(actors[len(actors)-1], "granted_scopes") + } + for _, scope := range scopes { + if field(scope, "actions").List().Len() > 0 && str(scope, "resource_owner_id") != owner { + t.Fatalf("scope on %s is owned by %q, want %q", kind, str(scope, "resource_owner_id"), owner) + } + } +} + +func listStrings(m protoreflect.Message, name string) []string { + l := field(m, name).List() + out := make([]string, l.Len()) + for i := range out { + out[i] = l.Get(i).String() + } + return out +} diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index 836e015b..b73112c2 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -279,7 +279,7 @@ func Bare(t *testing.T) *Kit { // manifests from their own workloads. // robin and assistant are declared for the artifact tests, which compose // them themselves. - for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation"} { + for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-store"} { launcher.Declare(Workload(module)) } _, signing, err := ed25519.GenerateKey(rand.Reader) diff --git a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go new file mode 100644 index 00000000..c27c5bd1 --- /dev/null +++ b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go @@ -0,0 +1,93 @@ +// The solution-principal target, against the local authority. It is the +// contract the local authority does not meet yet (conformance +// RunSolutionPrincipalTarget): run it with AUTHORITY_TARGET=solution-principal +// and it fails, naming what the published contract lacks first. +package registry_test + +import ( + "context" + "net" + "os" + "path/filepath" + "runtime" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "gateway/pkg/business/testkit" + "gateway/pkg/framework" + "gateway/plugins/authority" + "gateway/plugins/composition" + "gateway/plugins/installation" + "gateway/plugins/session" + + conformance "github.com/codefly-dev/authority/libraries/conformance/go" +) + +func TestTheLocalAuthorityMeetsTheSolutionPrincipalTarget(t *testing.T) { + if os.Getenv("AUTHORITY_TARGET") != "solution-principal" { + t.Skip("target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal to see what it lacks") + } + k := testkit.New(t) + // Two people whose own organisation grants reach one existing resource of + // the store's kind: one administers it, one reads it. + if err := k.State.LoadFixture(context.Background(), []byte(`roles: + - organization: acme + name: principal-store-admin + scopes: + - {kind: principal.collection, actions: [admin], resources: [{id: store-x}]} + - organization: acme + name: principal-store-reader + scopes: + - {kind: principal.collection, actions: [read], resources: [{id: store-x}]} +people: + - id: store-administrator + fixture_token: fixture-store-administrator + organization: acme + roles: [principal-store-admin] + - id: store-reader + fixture_token: fixture-store-reader + organization: acme + roles: [principal-store-reader] +`)); err != nil { + t.Fatal(err) + } + server := grpc.NewServer() + for _, p := range []framework.Plugin{ + &authority.Plugin{Issuer: k.Issuer}, + &composition.Plugin{State: k.State, Coordinate: "local", Verifier: testkit.ReleaseVerifier(t, "local"), Carrier: k.CarrierAuthority, Keys: testkit.ReleaseKeys()}, + &installation.Plugin{State: k.State}, &session.Plugin{State: k.State}, + } { + p.RegisterGRPC(server) + } + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + _, file, _, _ := runtime.Caller(0) + descriptors, err := os.ReadFile(filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "..", "..", "..", "libraries", "authority-client", "contract", "contract.binpb")) + if err != nil { + t.Fatal(err) + } + conformance.RunSolutionPrincipalTarget(t, conn, descriptors, conformance.SolutionPrincipalCase{ + Organization: "acme", Generation: 1, + A: conformance.SolutionUnderTest{Solution: "principal-a", Credential: k.Credential(t, "principal-a")}, + B: conformance.SolutionUnderTest{Solution: "principal-b", Credential: k.Credential(t, "principal-b")}, + Store: "principal-store", StoreCredential: k.Credential(t, "principal-store"), Kind: "principal.collection", + Admin: "fixture-alex", Viewer: "fixture-maya", Editor: "fixture-remi", Member: "fixture-dev", + Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", Existing: "store-x", + Uninstall: func(_ context.Context, id string) error { return k.State.Revoke(id) }, + SignManifest: testkit.SignDocument, + SignAuthority: testkit.SignAuthority, + DeliveryCredential: func(module string) string { return k.Credential(t, module) }, + HostCoordinate: "local", HostDomain: "local", + }) +} From 2a9ef9fd3e8173575cc5be305c131da205215131 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:55:07 -0400 Subject: [PATCH 2/5] test: revoke, team grants, the agent as the principal, owner with ids Coordinator decisions on the target: - the installation acting for itself is the solution principal: its owner id is U, not /agent; - the install request's role is the agent's role (editor's level, not installer's admin), and no person or team inherits it; - RevokeRole, by person or team, stops access at once (an old capability fails Check) and re-granting restores it; GrantRole by team is a team's access; - a scope naming an owner and ids admits only when both match, and a child never drops the ids it narrowed to. Co-Authored-By: Claude Opus 5.5 --- libraries/conformance/go/README.md | 11 +- .../conformance/go/solution_principal.go | 105 +++++++++++++++--- .../solution_principal_target_test.go | 9 +- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index a6af8870..950be554 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -73,8 +73,8 @@ single-organization inference remains a local convenience, not a shared promise. `RunSolutionPrincipalTarget` specifies solution identity before any host implements it. An installed solution is a principal of its organisation with a stable UUID, reused by a reinstall; its declared roles are permission levels on -the data that principal owns; granting a person a role (`GrantRole`) is the -access; a capability's scopes select resources by owner (`resource_owner_id`) +the data that principal owns; granting a person or a team a role (`GrantRole`) is the +access and revoking it (`RevokeRole`) ends it at once; a capability's scopes select resources by owner (`resource_owner_id`) rather than by id lists; an existing resource joins the principal only through `AuthorizeTransfer`, which requires admin the person holds from their own organisation grants. The suite first names every contract element the @@ -82,12 +82,15 @@ published descriptors lack, then runs these cases: - install creates the solution principal and its roles; a reinstall keeps its UUID - without a role on the solution a person is refused, naming the reason -- a viewer reads what the principal owns, and nothing another installation or a person owns -- a resource created through the solution is owned by its principal, the person its author (and the installation's agent creates for the same principal) +- a viewer reads what the principal owns, and nothing another installation or a person owns; a scope naming an owner and ids admits only when both match +- a resource created through the solution is owned by its principal, the person its author; the installation acting for itself is the principal (owner id U) and creates for it +- the install request's role is the agent's role, and no person's - an editor writes and a viewer cannot, each by name - another installation's members never reach the principal's data - a child never widens the owner or the actions it narrowed to - an existing resource joins the principal only by a transfer its person administers through their own grants (an installer role alone and the agent are refused) +- revoking a person's role stops access at once (an old capability fails Check); re-granting restores it +- a team's role is its members' access; revoking it stops them at once; re-granting restores it - uninstalling disables the principal at once and its data stays owned; a re-grant after reinstall restores access Run it against the local authority from `modules/authority/services/local/code` diff --git a/libraries/conformance/go/solution_principal.go b/libraries/conformance/go/solution_principal.go index 3060bb11..2d55c5d3 100644 --- a/libraries/conformance/go/solution_principal.go +++ b/libraries/conformance/go/solution_principal.go @@ -15,9 +15,12 @@ import ( // SolutionPrincipalCase is the TARGET contract for solution identity; no host // passes it yet. An installed solution is a principal of its organisation, with // a stable UUID like a person's. Its declared roles are permission levels on -// the data that principal owns, and granting a person a role is the access. -// A capability carries owner-selected scopes: {kind, actions, resource owner}, -// never lists of ids, so a module admits a resource whose owner is the scope's. +// the data that principal owns, and granting a person or a team a role is the +// access; revoking it ends the access at once. The installation acting for +// itself is that principal, holding the role its install request named. A +// capability carries owner-selected scopes: {kind, actions, resource owner}, +// so a module admits a resource whose owner is the scope's; a scope naming an +// owner and ids admits a resource only when both match. // // The suite delivers A, B (two solutions) and Store (the module serving Kind) // at Generation, installs A and B through InstallationService, and grants every @@ -26,6 +29,7 @@ import ( // organisation grants hold nothing on Kind. // - Viewer, Editor and Member hold no role on A or B until the suite grants // one; their organisation grants on Kind are irrelevant to the case. +// - TeamMember belongs to Team, and holds no role on A or B but through it. // - Administrator holds admin on Existing, and Reader holds read on Existing, // each from their own organisation grants at Store and on nothing else of // Kind. Existing is a resource of Kind no solution principal owns. @@ -39,6 +43,7 @@ type SolutionPrincipalCase struct { Store, StoreCredential, Kind string Generation uint64 Admin, Viewer, Editor, Member string + Team, TeamMember string Administrator, Reader string Existing string Uninstall func(ctx context.Context, installationID string) error @@ -63,6 +68,12 @@ var solutionPrincipalContract = []struct{ message, element string }{ {"host.installation.v1.GrantRoleRequest", "role"}, {"host.installation.v1.GrantRoleRequest", "person_id"}, {"host.installation.v1.GrantRoleRequest", "team_id"}, + {"host.installation.v1.InstallationService", "RevokeRole"}, + {"host.installation.v1.RevokeRoleRequest", "organization_id"}, + {"host.installation.v1.RevokeRoleRequest", "installation_id"}, + {"host.installation.v1.RevokeRoleRequest", "role"}, + {"host.installation.v1.RevokeRoleRequest", "person_id"}, + {"host.installation.v1.RevokeRoleRequest", "team_id"}, {"host.authority.v1.WorkScopeV1", "resource_owner_id"}, {"host.authority.v1.ResourceGrant", "owner"}, {"host.authority.v1.AuthorityService", "AuthorizeTransfer"}, @@ -88,7 +99,7 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des t.Fatalf("target: the published contract lacks %v", missing) } if f.Uninstall == nil || f.SignManifest == nil || f.SignAuthority == nil || f.DeliveryCredential == nil || f.Existing == "" || - f.Admin == "" || f.Viewer == "" || f.Editor == "" || f.Member == "" || f.Administrator == "" || f.Reader == "" { + f.Admin == "" || f.Viewer == "" || f.Editor == "" || f.Member == "" || f.Administrator == "" || f.Reader == "" || f.Team == "" || f.TeamMember == "" { t.Fatal("required conformance fixture missing: solution principal case") } ctx := context.Background() @@ -142,10 +153,23 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des return out } principal := func(s SolutionUnderTest) string { return str(installed[s.Solution], "principal_id") } - grant := func(t *testing.T, s SolutionUnderTest, role, token string) { + // role grants (GrantRole) or revokes (RevokeRole) one role on s for one + // subject: {"person_id": ...} or {"team_id": ...}. + role := func(t *testing.T, method string, s SolutionUnderTest, name string, subject map[string]any) { t.Helper() - c.must(t, asPerson(t, f.Admin), "host.installation.v1.InstallationService", "GrantRole", map[string]any{ - "organization_id": f.Organization, "installation_id": str(installed[s.Solution], "id"), "role": role, "person_id": personID(t, token)}) + request := map[string]any{"organization_id": f.Organization, "installation_id": str(installed[s.Solution], "id"), "role": name} + for k, v := range subject { + request[k] = v + } + c.must(t, asPerson(t, f.Admin), "host.installation.v1.InstallationService", method, request) + } + person := func(t *testing.T, token string) map[string]any { + return map[string]any{"person_id": personID(t, token)} + } + team := map[string]any{"team_id": f.Team} + grant := func(t *testing.T, s SolutionUnderTest, name, token string) { + t.Helper() + role(t, "GrantRole", s, name, person(t, token)) } issue := func(fields map[string]any) (protoreflect.Message, error) { return c.call(ctx, "host.authority.v1.AuthorityService", "Issue", fields) @@ -171,11 +195,15 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des return issue(map[string]any{"installation": map[string]any{"installation_id": str(installed[s.Solution], "id")}, "actor": map[string]any{"credential": s.Credential}, "audience": f.Store, "scopes": scopes}) } - owned := func(actions []string, owner string) map[string]any { - ask := scopeAsk(f.Kind, actions, nil) + owned := func(actions []string, owner string, ids ...string) map[string]any { + ask := scopeAsk(f.Kind, actions, ids) ask["resource_owner_id"] = owner return ask } + check := func(capability protoreflect.Message) error { + _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(capability, "token"), "audience": f.Store}) + return err + } transfer := func(token, to string) (protoreflect.Message, error) { return c.call(ctx, "host.authority.v1.AuthorityService", "AuthorizeTransfer", map[string]any{"token": token, "actor": map[string]any{"credential": f.StoreCredential}, "resource_kind": f.Kind, "resource_id": f.Existing, "to_principal_id": to}) @@ -227,8 +255,12 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des if len(grants) != 1 || str(grants[0], "owner") != principal(f.A) { t.Fatalf("listed %v, want exactly the resources principal %s owns", grants, principal(f.A)) } + // Owner and ids together narrow within the owner: both must match. + narrowed := must(t)(through(t, f.Viewer, f.A, owned([]string{"read"}, principal(f.A), "u-doc"))) + exactOwnedScopeIDs(t, msg(narrowed, "context"), f.Kind, []string{"read"}, principal(f.A), []string{"u-doc"}) for name, ask := range map[string]any{ "another installation's data": owned([]string{"read"}, principal(f.B)), + "another owner's ids": owned([]string{"read"}, principal(f.B), "u-doc"), "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), "a resource it does not own": scopeAsk(f.Kind, []string{"read"}, []string{f.Existing}), } { @@ -244,13 +276,25 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des if str(wc, "owner_principal_id") != personID(t, f.Editor) || str(wc, "owner_principal_kind") != "human" { t.Fatalf("the author is %s (%s), want the person", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind")) } - // The installation acting for itself creates for the same principal. + // The installation acting for itself is the principal: owner and + // author are both U, and it creates for U. byAgent := must(t)(agent(f.A, owned([]string{"create"}, principal(f.A)))) exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create"}, principal(f.A)) - if str(msg(byAgent, "context"), "owner_principal_kind") != "agent" { - t.Fatal("an installation acting for itself is its agent") + if wc := msg(byAgent, "context"); str(wc, "owner_principal_kind") != "agent" || str(wc, "owner_principal_id") != principal(f.A) { + t.Fatalf("the installation acting for itself is %s (%s), want the solution principal %s", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind"), principal(f.A)) } }) + t.Run("the install request's role is the agent's role, and no person's", func(t *testing.T) { + // A was installed with role editor: the agent holds exactly editor's + // level on the principal's data, and not installer's admin. + byAgent := must(t)(agent(f.A)) + exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create", "read", "write"}, principal(f.A)) + _, err := agent(f.A, owned([]string{"admin"}, principal(f.A))) + refused(t, err, codes.PermissionDenied, "beyond") + // Nobody inherits it: a team member is refused until the team is granted. + _, err = root(t, f.TeamMember, f.A) + refused(t, err, codes.PermissionDenied, "holds no role") + }) t.Run("an editor writes and a viewer cannot, each by name", func(t *testing.T) { out := must(t)(through(t, f.Editor, f.A, owned([]string{"write"}, principal(f.A)))) exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"write"}, principal(f.A)) @@ -280,6 +324,10 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des readOnly := must(t)(through(t, f.Editor, f.A, owned([]string{"read"}, principal(f.A)))) _, err := hop(readOnly, owned([]string{"write"}, principal(f.A))) refused(t, err, codes.PermissionDenied, "widens") + oneDoc := must(t)(through(t, f.Viewer, f.A, owned([]string{"read"}, principal(f.A), "u-doc"))) + exactOwnedScopeIDs(t, msg(must(t)(hop(oneDoc)), "context"), f.Kind, []string{"read"}, principal(f.A), []string{"u-doc"}) + _, err = hop(oneDoc, owned([]string{"read"}, principal(f.A))) + refused(t, err, codes.PermissionDenied, "widens") }) t.Run("an existing resource joins the principal only by a transfer its person administers through their own grants", func(t *testing.T) { grant(t, f.A, "viewer", f.Administrator) @@ -302,15 +350,34 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des _, err = transfer(str(byAgent, "token"), principal(f.A)) refused(t, err, codes.PermissionDenied, "a person's act") }) + t.Run("revoking a person's role stops access at once; re-granting restores it", func(t *testing.T) { + before := must(t)(through(t, f.Viewer, f.A)) + role(t, "RevokeRole", f.A, "viewer", person(t, f.Viewer)) + refused(t, check(before), codes.PermissionDenied, "") + _, err := root(t, f.Viewer, f.A) + refused(t, err, codes.PermissionDenied, "holds no role") + grant(t, f.A, "viewer", f.Viewer) + exactOwnedScope(t, msg(must(t)(through(t, f.Viewer, f.A)), "context"), f.Kind, []string{"read"}, principal(f.A)) + }) + t.Run("a team's role is its members' access; revoking it stops them at once; re-granting restores it", func(t *testing.T) { + role(t, "GrantRole", f.A, "viewer", team) + before := must(t)(through(t, f.TeamMember, f.A)) + exactOwnedScope(t, msg(before, "context"), f.Kind, []string{"read"}, principal(f.A)) + role(t, "RevokeRole", f.A, "viewer", team) + refused(t, check(before), codes.PermissionDenied, "") + _, err := root(t, f.TeamMember, f.A) + refused(t, err, codes.PermissionDenied, "holds no role") + role(t, "GrantRole", f.A, "viewer", team) + exactOwnedScope(t, msg(must(t)(through(t, f.TeamMember, f.A)), "context"), f.Kind, []string{"read"}, principal(f.A)) + }) t.Run("uninstalling disables the principal at once and its data stays owned; a re-grant after reinstall restores access", func(t *testing.T) { before := must(t)(through(t, f.Viewer, f.A)) u := principal(f.A) if err := f.Uninstall(ctx, str(installed[f.A.Solution], "id")); err != nil { t.Fatal(err) } - _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(before, "token"), "audience": f.Store}) - refused(t, err, codes.PermissionDenied, "") - _, err = agent(f.A) + refused(t, check(before), codes.PermissionDenied, "") + _, err := agent(f.A) refused(t, err, codes.PermissionDenied, "") if install(t, f.A); principal(f.A) != u { t.Fatalf("reinstalled as %s, want %s", principal(f.A), u) @@ -352,7 +419,13 @@ func (c *client) missingContract() []string { // on resources owned by owner: no id list, no other kind. func exactOwnedScope(t *testing.T, wc protoreflect.Message, kind string, actions []string, owner string) { t.Helper() - exactEffectiveScope(t, wc, kind, actions, nil) + exactOwnedScopeIDs(t, wc, kind, actions, owner, nil) +} + +// exactOwnedScopeIDs is exactOwnedScope narrowed to ids within the owner. +func exactOwnedScopeIDs(t *testing.T, wc protoreflect.Message, kind string, actions []string, owner string, ids []string) { + t.Helper() + exactEffectiveScope(t, wc, kind, actions, ids) scopes := listMsgs(wc, "authority_scopes") if actors := listMsgs(wc, "actor_chain"); len(actors) > 0 { scopes = listMsgs(actors[len(actors)-1], "granted_scopes") diff --git a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go index c27c5bd1..2847f317 100644 --- a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go @@ -32,7 +32,8 @@ func TestTheLocalAuthorityMeetsTheSolutionPrincipalTarget(t *testing.T) { } k := testkit.New(t) // Two people whose own organisation grants reach one existing resource of - // the store's kind: one administers it, one reads it. + // the store's kind (one administers it, one reads it), and a member of a + // team no installation is granted to. if err := k.State.LoadFixture(context.Background(), []byte(`roles: - organization: acme name: principal-store-admin @@ -51,6 +52,11 @@ people: fixture_token: fixture-store-reader organization: acme roles: [principal-store-reader] + - id: principal-team-member + fixture_token: fixture-principal-team-member + organization: acme + roles: [] + teams: [principal-team] `)); err != nil { t.Fatal(err) } @@ -84,6 +90,7 @@ people: Store: "principal-store", StoreCredential: k.Credential(t, "principal-store"), Kind: "principal.collection", Admin: "fixture-alex", Viewer: "fixture-maya", Editor: "fixture-remi", Member: "fixture-dev", Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", Existing: "store-x", + Team: "principal-team", TeamMember: "fixture-principal-team-member", Uninstall: func(_ context.Context, id string) error { return k.State.Revoke(id) }, SignManifest: testkit.SignDocument, SignAuthority: testkit.SignAuthority, From 718a834b2acf49325f7dfaeff472b51a5b01d734 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:12:07 -0400 Subject: [PATCH 3/5] test: the solution-principal target, revised for spec review r75 Coordinator decisions: confinement to the principal's data; one identity (agent_principal_id deleted, the agent's owner id is principal_id, and Run's installation case reads installationPrincipal so one host passes both); fail-closed modules (hop and Claims targets, and no owner-selected scopes to an audience that does not declare owner_aware); who may grant (an organisation administrator or the solution's installer) with every malformed grant refused; a second organisation; old capabilities dead after re-grant and reinstall; transfer with a store-attested from_owner_id, only from a person or the organisation, needing the person's own admin and editor on the principal; the installer as an explicit grant. Also: GrantToTeam, GrantToTeamRequest, team_ids required absent; ListRoleGrants; ResourceGrant.resource_owner_id qualifies a match outside the oneof, asserted on a narrowed capability; team- and organisation-leaving hooks; a person, installation or organisation id refused as owner; a dedicated person with admin on one resource; independent cases; named revocation reasons; the agent through its operation binding; disabled- principal refusals; own-kind scopes owner-selected; a revoke leaves unrelated capabilities alive; transfer id rules. Co-Authored-By: Claude Opus 5.5 --- .../go/workcontext/owner_target_test.go | 64 ++ libraries/conformance/go/README.md | 75 +- libraries/conformance/go/conformance.go | 7 +- .../conformance/go/solution_principal.go | 909 ++++++++++++------ libraries/hop/go/owner_target_test.go | 52 + .../code/pkg/business/testkit/testkit.go | 2 +- .../solution_principal_target_test.go | 45 +- 7 files changed, 823 insertions(+), 331 deletions(-) create mode 100644 libraries/authority-client/go/workcontext/owner_target_test.go create mode 100644 libraries/hop/go/owner_target_test.go diff --git a/libraries/authority-client/go/workcontext/owner_target_test.go b/libraries/authority-client/go/workcontext/owner_target_test.go new file mode 100644 index 00000000..9912c5f8 --- /dev/null +++ b/libraries/authority-client/go/workcontext/owner_target_test.go @@ -0,0 +1,64 @@ +package workcontext_test + +import ( + "os" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" +) + +// ownedScope is {kind, [action], resource_owner_id: owner, resource_ids: ids}, +// with the owner selector written as WorkScopeV1 field 4: the target's field, +// carried as an unknown field until core declares it. +func ownedScope(t *testing.T, kind, action, owner string, ids ...string) *basev0.WorkScopeV1 { + t.Helper() + raw, err := proto.Marshal(&basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}, ResourceIds: ids}) + if err != nil { + t.Fatal(err) + } + raw = protowire.AppendString(protowire.AppendTag(raw, 4, protowire.BytesType), owner) + out := &basev0.WorkScopeV1{} + if err := proto.Unmarshal(raw, out); err != nil { + t.Fatal(err) + } + return out +} + +// ownerChecker is the target's owner-aware question: may the claims act with +// action on resourceID of kind, whose owner is ownerID? +type ownerChecker interface { + AllowsOwned(kind, action, resourceID, ownerID string) bool +} + +// TARGET (solution principal; conformance RunSolutionPrincipalTarget): run +// with AUTHORITY_TARGET=solution-principal. Claims take the resource's owner +// and refuse a mismatch; the owner-blind Allows never admits an owner-selected +// scope; an owner with ids admits only when both match. +func TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner(t *testing.T) { + if os.Getenv("AUTHORITY_TARGET") != "solution-principal" { + t.Skip("target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal") + } + c := body(t, "session") + c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", "principal-u"), ownedScope(t, "note", "read", "principal-u", "n1")} + claims := read(t, c) + if claims.Allows("record", "read", "") || claims.Allows("record", "read", "r1") || claims.Allows("note", "read", "n1") { + t.Fatal("target: the owner-blind Allows admits an owner-selected scope") + } + owned, ok := any(claims).(ownerChecker) + if !ok { + t.Fatal("target: Claims has no AllowsOwned(kind, action, resourceID, ownerID)") + } + for _, q := range []struct { + kind, id, owner string + allowed bool + }{ + {"record", "r1", "principal-u", true}, {"record", "r1", "principal-v", false}, {"record", "r1", "", false}, + {"note", "n1", "principal-u", true}, {"note", "n2", "principal-u", false}, {"note", "n1", "principal-v", false}, + } { + if owned.AllowsOwned(q.kind, "read", q.id, q.owner) != q.allowed { + t.Fatalf("target: AllowsOwned(%s, read, %s, %s) want %v", q.kind, q.id, q.owner, q.allowed) + } + } +} diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 950be554..6f674786 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -71,28 +71,53 @@ single-organization inference remains a local convenience, not a shared promise. ## Target: solution principal (not yet part of `Run`) `RunSolutionPrincipalTarget` specifies solution identity before any host -implements it. An installed solution is a principal of its organisation with a -stable UUID, reused by a reinstall; its declared roles are permission levels on -the data that principal owns; granting a person or a team a role (`GrantRole`) is the -access and revoking it (`RevokeRole`) ends it at once; a capability's scopes select resources by owner (`resource_owner_id`) -rather than by id lists; an existing resource joins the principal only through -`AuthorizeTransfer`, which requires admin the person holds from their own -organisation grants. The suite first names every contract element the -published descriptors lack, then runs these cases: - -- install creates the solution principal and its roles; a reinstall keeps its UUID -- without a role on the solution a person is refused, naming the reason -- a viewer reads what the principal owns, and nothing another installation or a person owns; a scope naming an owner and ids admits only when both match -- a resource created through the solution is owned by its principal, the person its author; the installation acting for itself is the principal (owner id U) and creates for it -- the install request's role is the agent's role, and no person's -- an editor writes and a viewer cannot, each by name -- another installation's members never reach the principal's data -- a child never widens the owner or the actions it narrowed to -- an existing resource joins the principal only by a transfer its person administers through their own grants (an installer role alone and the agent are refused) -- revoking a person's role stops access at once (an old capability fails Check); re-granting restores it -- a team's role is its members' access; revoking it stops them at once; re-granting restores it -- uninstalling disables the principal at once and its data stays owned; a re-grant after reinstall restores access - -Run it against the local authority from `modules/authority/services/local/code` -with `AUTHORITY_TARGET=solution-principal go test ./plugins/registry_test -run SolutionPrincipal`. -It joins `Run` once a host implements it. +implements it. The model, in full, is the doc comment on +`SolutionPrincipalCase`: an installed solution is a principal U of its +organisation (a stable UUID, reused by a reinstall, disabled by an uninstall, +distinct per organisation); the installation acting for itself is U, with the +install request's role; the solution's roles are levels on U's data, granted +and revoked by an organisation administrator or the solution's installer +(`GrantRole`, `RevokeRole`, `ListRoleGrants`); a capability through the +solution is confined to U's data, by owner-selected scopes +(`WorkScopeV1.resource_owner_id`, field 4), issued only to an audience that +declares owner-aware checking; an existing person- or organisation-owned +resource joins U only by `AuthorizeTransfer`, with the person's own admin on it +and at least editor on U. The target deletes `GrantToTeam`, `team_ids` and +`agent_principal_id`. + +The suite first names what the published contract lacks and what it still +carries, then runs these cases, each making its own grants: + +1. install creates the solution principal and its roles; a reinstall keeps its UUID; another organisation's is another principal +2. the install request's role is the agent's, and no person's: the owner and the organisation administrator hold no role (the agent's owner id is U; it needs its operation binding) +3. without a role on the solution a person is refused, naming the reason +4. a viewer reads exactly what the principal owns (root, child, Check, `ListResources` with owner, owner with ids), and nothing a person, an installation, the organisation or another principal owns +5. a capability through the solution is confined to the principal's data; the person's own grants never flow in +6. owner-selected scopes are issued only to an audience that checks owners +7. a resource created through the solution is owned by its principal, the person its author +8. an editor writes and a viewer cannot, each by name +9. an organisation administrator or the solution's installer grants; anyone else, and every malformed grant, is refused by name +10. another installation's and another organisation's members never reach the principal's data +11. a child never widens the owner, the actions or the ids it narrowed to +12. an existing resource joins the principal only by a transfer from a person or the organisation, administered through the person's own grants, by an editor of the principal (and nine named refusals) +13. revoking a person's role stops their access at once, theirs alone; their old capabilities stay dead after a re-grant +14. a team's role is its members' access; revoking it or leaving the team stops them at once; old capabilities stay dead after a re-grant +15. a person leaving the organisation loses the solution's data at once +16. uninstalling disables the principal at once and its data stays owned; old capabilities stay dead after reinstall and re-grant + +Two library targets pin the modules' side: `libraries/hop/go` +`TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind` (the stamp never flattens an +owner-selected scope into a whole-kind one, and owner-blind `Allows` refuses +it) and `libraries/authority-client/go/workcontext` +`TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner` (`AllowsOwned` takes the +resource's owner; owner and ids both match). + +When the target joins `Run`, it supersedes Run's `GrantToTeam`/`team_ids` +steps (the installation lifecycle's exposure, the callee-staleness probe's +grant), and Run reads the installation's acting principal through +`installationPrincipal`, so one host passes both. + +Run everything with `AUTHORITY_TARGET=solution-principal`: from +`modules/authority/services/local/code`, +`go test ./plugins/registry_test -run SolutionPrincipal`, and `go test ./...` +in `libraries/hop/go` and `libraries/authority-client/go`. Each skips without it. diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index eeb5ad89..fa8eee5c 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -486,11 +486,14 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt installation = i } } - if installation == nil || enumName(installation, "status") != "STATUS_ACTIVE" || str(installation, "agent_principal_id") == "" { + // The principal the installation acts as for itself: agent_principal_id + // today, principal_id under the solution-principal target, which + // deletes agent_principal_id (installationPrincipal). + if installation == nil || enumName(installation, "status") != "STATUS_ACTIVE" || installationPrincipal(installation) == "" { t.Fatalf("installed solution = %v", installation) } out := c.must(t, ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{"installation": map[string]any{"installation_id": str(installation, "id")}, "audience": f.InstalledAudience, "actor": map[string]any{"credential": f.InstalledCredential}}) - if str(msg(out, "context"), "owner_principal_id") != str(installation, "agent_principal_id") { + if str(msg(out, "context"), "owner_principal_id") != installationPrincipal(installation) { t.Fatalf("installation issue: %v", out) } c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.InstalledAudience}) diff --git a/libraries/conformance/go/solution_principal.go b/libraries/conformance/go/solution_principal.go index 2d55c5d3..601fa3c5 100644 --- a/libraries/conformance/go/solution_principal.go +++ b/libraries/conformance/go/solution_principal.go @@ -4,6 +4,7 @@ import ( "context" "regexp" "slices" + "strings" "testing" "github.com/codefly-dev/core/solutionhost" @@ -13,43 +14,62 @@ import ( ) // SolutionPrincipalCase is the TARGET contract for solution identity; no host -// passes it yet. An installed solution is a principal of its organisation, with -// a stable UUID like a person's. Its declared roles are permission levels on -// the data that principal owns, and granting a person or a team a role is the -// access; revoking it ends the access at once. The installation acting for -// itself is that principal, holding the role its install request named. A -// capability carries owner-selected scopes: {kind, actions, resource owner}, -// so a module admits a resource whose owner is the scope's; a scope naming an -// owner and ids admits a resource only when both match. +// passes it yet. // -// The suite delivers A, B (two solutions) and Store (the module serving Kind) -// at Generation, installs A and B through InstallationService, and grants every -// role itself. The host supplies people and one existing resource: -// - Admin administers Organization; it installs and grants. Its own -// organisation grants hold nothing on Kind. -// - Viewer, Editor and Member hold no role on A or B until the suite grants -// one; their organisation grants on Kind are irrelevant to the case. -// - TeamMember belongs to Team, and holds no role on A or B but through it. -// - Administrator holds admin on Existing, and Reader holds read on Existing, -// each from their own organisation grants at Store and on nothing else of -// Kind. Existing is a resource of Kind no solution principal owns. +// An installed solution is a principal of its organisation (U), with a stable +// UUID like a person's: one per (organisation, solution), reused by a +// reinstall, disabled by an uninstall. The installation acting for itself IS +// U, holding the role its install request names; nobody else inherits that +// role. The solution's declared roles are permission levels on U's data: +// granting a person or a team a role (GrantRole) is the access, revoking it +// (RevokeRole) ends it at once, and ListRoleGrants answers who holds which. +// An organisation administrator, or a holder of the solution's installer role, +// grants and revokes. // -// Uninstall is the host's uninstall path for an installation id (the interface -// has no uninstall operation). The host's install step must converge for the -// suite's solutions, so Install answers an active installation. +// A capability issued through the solution is CONFINED to U's data: its +// scopes are owner-selected, {kind, actions, resource_owner_id: U}, from the +// solution's roles alone; a person's own organisation grants never flow in. A +// scope naming an owner and ids admits a resource only when both match. The +// authority issues owner-selected scopes only to an audience whose delivery +// declares owner-aware checking (AuthorityManifestV1.owner_aware). +// +// An existing resource joins U only by AuthorizeTransfer: the store (the +// kind's serving module, which attests the resource exists and who owns it +// now) asks; the authority allows it from a person- or organisation-owned +// resource, never from another solution principal, when the person holds admin +// on it from their own organisation grants AND at least editor on U. The store +// then records U as owner; the original creator stays the author, and the +// previous owner keeps nothing through ownership. +// +// The suite delivers A, B, Store and LegacyStore, installs A and B, and makes +// every grant itself. The host supplies, at Organization unless stated: +// - Admin: an organisation administrator (whatever its own grants on Kind). +// - Viewer, Editor, Member, Installer, TeamMember, TeamLeaver, OrgLeaver: +// members with no role on A or B but what the suite grants; TeamMember and +// TeamLeaver belong to Team. +// - Administrator holds admin, and Reader holds read, on Existing from their +// own organisation grants at Store, and nothing else of Kind. +// - OtherAdmin and OtherMember: an administrator and a member of +// OtherOrganization. +// - Existing: a resource of Kind owned by Organization. +// - Uninstall, RemoveFromTeam, RemoveFromOrganization: the host's own paths +// (the interface has none). Install must converge for the suite's solutions. type SolutionPrincipalCase struct { - Organization string - A, B SolutionUnderTest - Store, StoreCredential, Kind string - Generation uint64 - Admin, Viewer, Editor, Member string - Team, TeamMember string - Administrator, Reader string - Existing string - Uninstall func(ctx context.Context, installationID string) error - SignManifest, SignAuthority func(document []byte) ([]byte, error) - DeliveryCredential func(module string) string - HostCoordinate, HostDomain string + Organization, OtherOrganization string + A, B SolutionUnderTest + Store, StoreCredential, Kind string + LegacyStore, LegacyStoreCredential, LegacyKind string + Generation uint64 + Admin, Viewer, Editor, Member, Installer string + Team, TeamMember, TeamLeaver, OrgLeaver string + Administrator, Reader, OtherAdmin, OtherMember string + Existing string + Uninstall func(ctx context.Context, installationID string) error + RemoveFromTeam func(ctx context.Context, personID, team string) error + RemoveFromOrganization func(ctx context.Context, personID string) error + SignManifest, SignAuthority func(document []byte) ([]byte, error) + DeliveryCredential func(module string) string + HostCoordinate, HostDomain string } // SolutionUnderTest is one solution the suite delivers and installs. @@ -57,362 +77,689 @@ type SolutionUnderTest struct { Solution, Credential string } -// solutionPrincipalContract is what the target adds to the published contract. -// Its absence is the first, named failure of every case. +// solutionPrincipalContract is what the target adds; solutionPrincipalRemoved +// is what it deletes (one mechanism per job). Both are checked first. var solutionPrincipalContract = []struct{ message, element string }{ {"host.installation.v1.Installation", "principal_id"}, {"host.installation.v1.Installation", "roles"}, {"host.installation.v1.InstallationService", "GrantRole"}, + {"host.installation.v1.InstallationService", "RevokeRole"}, + {"host.installation.v1.InstallationService", "ListRoleGrants"}, {"host.installation.v1.GrantRoleRequest", "organization_id"}, {"host.installation.v1.GrantRoleRequest", "installation_id"}, {"host.installation.v1.GrantRoleRequest", "role"}, {"host.installation.v1.GrantRoleRequest", "person_id"}, {"host.installation.v1.GrantRoleRequest", "team_id"}, - {"host.installation.v1.InstallationService", "RevokeRole"}, {"host.installation.v1.RevokeRoleRequest", "organization_id"}, {"host.installation.v1.RevokeRoleRequest", "installation_id"}, {"host.installation.v1.RevokeRoleRequest", "role"}, {"host.installation.v1.RevokeRoleRequest", "person_id"}, {"host.installation.v1.RevokeRoleRequest", "team_id"}, + {"host.installation.v1.ListRoleGrantsRequest", "organization_id"}, + {"host.installation.v1.ListRoleGrantsRequest", "installation_id"}, + {"host.installation.v1.ListRoleGrantsResponse", "grants"}, + {"host.installation.v1.RoleGrant", "role"}, + {"host.installation.v1.RoleGrant", "person_id"}, + {"host.installation.v1.RoleGrant", "team_id"}, {"host.authority.v1.WorkScopeV1", "resource_owner_id"}, - {"host.authority.v1.ResourceGrant", "owner"}, + {"host.authority.v1.ResourceGrant", "resource_owner_id"}, {"host.authority.v1.AuthorityService", "AuthorizeTransfer"}, {"host.authority.v1.AuthorizeTransferRequest", "token"}, {"host.authority.v1.AuthorizeTransferRequest", "actor"}, {"host.authority.v1.AuthorizeTransferRequest", "resource_kind"}, {"host.authority.v1.AuthorizeTransferRequest", "resource_id"}, + {"host.authority.v1.AuthorizeTransferRequest", "from_owner_id"}, {"host.authority.v1.AuthorizeTransferRequest", "to_principal_id"}, {"host.authority.v1.AuthorizeTransferResponse", "principal_id"}, + {"host.authority.v1.AuthorizeTransferResponse", "authorized_by"}, + {"host.composition.v1.AuthorityManifestV1", "owner_aware"}, +} + +var solutionPrincipalRemoved = []struct{ message, element string }{ + {"host.installation.v1.InstallationService", "GrantToTeam"}, + {"host.installation.v1.GrantToTeamRequest", ""}, + {"host.installation.v1.Installation", "team_ids"}, + {"host.installation.v1.Installation", "agent_principal_id"}, } -var canonicalUUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var principalUUID = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // RunSolutionPrincipalTarget drives the target over one connection. It is not -// part of Run until a host implements it. +// part of Run until a host implements it; when it joins, it supersedes Run's +// GrantToTeam and team_ids cases (installation_lifecycle.go's exposure steps, +// the callee-staleness probe's GrantToTeam) and reads the installation's +// acting principal as principal_id (installationPrincipal). func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f SolutionPrincipalCase) { t.Helper() c, err := newClient(conn, descriptors) if err != nil { t.Fatalf("conformance: %v", err) } - if missing := c.missingContract(); len(missing) > 0 { - t.Fatalf("target: the published contract lacks %v", missing) + if problems := c.contractProblems(); len(problems) > 0 { + t.Fatalf("target: the published contract %s", strings.Join(problems, "; ")) } - if f.Uninstall == nil || f.SignManifest == nil || f.SignAuthority == nil || f.DeliveryCredential == nil || f.Existing == "" || - f.Admin == "" || f.Viewer == "" || f.Editor == "" || f.Member == "" || f.Administrator == "" || f.Reader == "" || f.Team == "" || f.TeamMember == "" { + if f.Uninstall == nil || f.RemoveFromTeam == nil || f.RemoveFromOrganization == nil || f.SignManifest == nil || f.SignAuthority == nil || + f.DeliveryCredential == nil || f.Existing == "" || f.OtherOrganization == "" || f.LegacyStore == "" || f.Team == "" || + slices.Contains([]string{f.Admin, f.Viewer, f.Editor, f.Member, f.Installer, f.TeamMember, f.TeamLeaver, f.OrgLeaver, f.Administrator, f.Reader, f.OtherAdmin, f.OtherMember}, "") { t.Fatal("required conformance fixture missing: solution principal case") } ctx := context.Background() - const binding, self = "store", "self" - all := []string{f.Kind + ":admin", f.Kind + ":create", f.Kind + ":read", f.Kind + ":write"} + const binding, legacy, self = "store", "legacy", "self" delivery := Fixture{SignManifest: f.SignManifest, SignAuthority: f.SignAuthority, DeliveryCredential: f.DeliveryCredential, HostCoordinate: f.HostCoordinate, HostDomain: f.HostDomain} + every := []string{"admin", "create", "read", "write"} if _, err := c.deliver(ctx, delivery, f.Store, "module", f.Generation, []string{f.Kind}, - map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, []string{"admin", "create", "read", "write"}, nil)}}, 0, + map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, every, nil)}, "owner_aware": true}, 0, solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read", f.Kind + ":write"}}}); err != nil { t.Fatalf("deliver %s: %v", f.Store, err) } - // Each role is a level on the principal's data: no role names an owner, - // since the owner is always the installation's own principal. + // The legacy store does not declare owner-aware checking. + if _, err := c.deliver(ctx, delivery, f.LegacyStore, "module", f.Generation, []string{f.LegacyKind}, + map[string]any{"scope_ceilings": []any{scopeAsk(f.LegacyKind, []string{"read"}, nil)}}, 0); err != nil { + t.Fatalf("deliver %s: %v", f.LegacyStore, err) + } + // Each role is a level on the principal's data; no role names an owner. for _, s := range []SolutionUnderTest{f.A, f.B} { own := s.Solution + ".note" doc := map[string]any{ "scope_ceilings": []any{scopeAsk(own, []string{"read", "write"}, nil)}, "roles": []any{ - map[string]any{"name": "installer", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, []string{"admin", "create", "read", "write"}, nil)}}, + map[string]any{"name": "installer", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, every, nil)}}, map[string]any{"name": "editor", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, []string{"create", "read", "write"}, nil)}}, - map[string]any{"name": "viewer", "scopes": []any{scopeAsk(own, []string{"read"}, nil), scopeAsk(f.Kind, []string{"read"}, nil)}}, + map[string]any{"name": "viewer", "scopes": []any{scopeAsk(own, []string{"read"}, nil), scopeAsk(f.Kind, []string{"read"}, nil), scopeAsk(f.LegacyKind, []string{"read"}, nil)}}, }, } if _, err := c.deliver(ctx, delivery, s.Solution, "solution", f.Generation, []string{own}, doc, 0, - solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": all}}); err != nil { + solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":admin", f.Kind + ":create", f.Kind + ":read", f.Kind + ":write"}}}, + solutionhost.AuthorityBinding{ID: legacy, Audience: f.LegacyStore, Operations: map[string][]string{"invoke": {f.LegacyKind + ":read"}}}); err != nil { t.Fatalf("deliver %s: %v", s.Solution, err) } } - sessions := map[string]protoreflect.Message{} - session := func(t *testing.T, token string) protoreflect.Message { - t.Helper() - if s, ok := sessions[token]; ok { - return s - } - s := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": token}) - sessions[token] = s - return s - } - personID := func(t *testing.T, token string) string { return str(session(t, token), "person_id") } - asPerson := func(t *testing.T, token string) context.Context { return as(ctx, session(t, token)) } - installed := map[string]protoreflect.Message{} - install := func(t *testing.T, s SolutionUnderTest) protoreflect.Message { - t.Helper() - out := c.must(t, asPerson(t, f.Admin), "host.installation.v1.InstallationService", "Install", map[string]any{ - "organization_id": f.Organization, "target": s.Solution, "role": "editor", "owner_person_id": personID(t, f.Admin)}) - if enumName(out, "status") != "STATUS_ACTIVE" { - t.Fatalf("%s is %s; the host's install step must converge for the suite's solutions", s.Solution, enumName(out, "status")) - } - installed[s.Solution] = out - return out - } - principal := func(s SolutionUnderTest) string { return str(installed[s.Solution], "principal_id") } - // role grants (GrantRole) or revokes (RevokeRole) one role on s for one - // subject: {"person_id": ...} or {"team_id": ...}. - role := func(t *testing.T, method string, s SolutionUnderTest, name string, subject map[string]any) { - t.Helper() - request := map[string]any{"organization_id": f.Organization, "installation_id": str(installed[s.Solution], "id"), "role": name} - for k, v := range subject { - request[k] = v - } - c.must(t, asPerson(t, f.Admin), "host.installation.v1.InstallationService", method, request) - } - person := func(t *testing.T, token string) map[string]any { - return map[string]any{"person_id": personID(t, token)} - } - team := map[string]any{"team_id": f.Team} - grant := func(t *testing.T, s SolutionUnderTest, name, token string) { - t.Helper() - role(t, "GrantRole", s, name, person(t, token)) - } - issue := func(fields map[string]any) (protoreflect.Message, error) { - return c.call(ctx, "host.authority.v1.AuthorityService", "Issue", fields) - } - root := func(t *testing.T, token string, s SolutionUnderTest) (protoreflect.Message, error) { - return c.call(asPerson(t, token), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": s.Solution}) - } - // through is a person at s, then s's hop to Store asking scopes. - through := func(t *testing.T, token string, s SolutionUnderTest, scopes ...any) (protoreflect.Message, error) { - t.Helper() - r, err := root(t, token, s) - if err != nil { - return nil, err - } - return issue(map[string]any{"parent_token": str(r, "token"), "actor": map[string]any{"credential": s.Credential}, - "operation_binding_id": binding, "audience": f.Store, "scopes": scopes}) - } - hop := func(parent protoreflect.Message, scopes ...any) (protoreflect.Message, error) { - return issue(map[string]any{"parent_token": str(parent, "token"), "actor": map[string]any{"credential": f.StoreCredential}, - "operation_binding_id": self, "audience": f.Store, "scopes": scopes}) - } - agent := func(s SolutionUnderTest, scopes ...any) (protoreflect.Message, error) { - return issue(map[string]any{"installation": map[string]any{"installation_id": str(installed[s.Solution], "id")}, - "actor": map[string]any{"credential": s.Credential}, "audience": f.Store, "scopes": scopes}) - } - owned := func(actions []string, owner string, ids ...string) map[string]any { - ask := scopeAsk(f.Kind, actions, ids) - ask["resource_owner_id"] = owner - return ask - } - check := func(capability protoreflect.Message) error { - _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(capability, "token"), "audience": f.Store}) - return err - } - transfer := func(token, to string) (protoreflect.Message, error) { - return c.call(ctx, "host.authority.v1.AuthorityService", "AuthorizeTransfer", map[string]any{"token": token, - "actor": map[string]any{"credential": f.StoreCredential}, "resource_kind": f.Kind, "resource_id": f.Existing, "to_principal_id": to}) - } - // must(t)(call()) stops the case at an unexpected refusal. - must := func(t *testing.T) func(protoreflect.Message, error) protoreflect.Message { - return func(out protoreflect.Message, err error) protoreflect.Message { - t.Helper() - if err != nil { - t.Fatal(err) - } - return out - } - } + sp := &principalSuite{c: c, f: f, ctx: ctx, sessions: map[string]protoreflect.Message{}, installed: map[string]protoreflect.Message{}} + A, B := f.A, f.B + U := func(t *testing.T) string { return sp.principal(t, f.Organization, A) } + V := func(t *testing.T) string { return sp.principal(t, f.Organization, B) } - t.Run("install creates the solution principal and its roles; a reinstall keeps its UUID", func(t *testing.T) { - a, b := install(t, f.A), install(t, f.B) + t.Run("install creates the solution principal and its roles; a reinstall keeps its UUID; another organisation's is another principal", func(t *testing.T) { + a, b := sp.ensure(t, f.Organization, A), sp.ensure(t, f.Organization, B) for _, inst := range []protoreflect.Message{a, b} { - id := str(inst, "principal_id") - if !canonicalUUID.MatchString(id) || id == str(inst, "id") || id == str(inst, "agent_principal_id") { + if id := str(inst, "principal_id"); !principalUUID.MatchString(id) || id == str(inst, "id") { t.Fatalf("principal_id %q is not a principal UUID of its own", id) } if got := slices.Sorted(slices.Values(listStrings(inst, "roles"))); !slices.Equal(got, []string{"editor", "installer", "viewer"}) { t.Fatalf("the installation's roles are %v, want the solution's declared roles", got) } } - if principal(f.A) == principal(f.B) { + if U(t) == V(t) { t.Fatal("two installations share a principal") } - before := principal(f.A) - if err := f.Uninstall(ctx, str(a, "id")); err != nil { - t.Fatal(err) + if other := sp.principal(t, f.OtherOrganization, A); other == U(t) { + t.Fatal("one solution installed in two organisations shares a principal") + } + before := U(t) + sp.uninstall(t, f.Organization, A) + if after := U(t); after != before { + t.Fatalf("a reinstall made principal %s, want %s: its data would be orphaned", after, before) } - if install(t, f.A); principal(f.A) != before { - t.Fatalf("a reinstall made principal %s, want %s: its data would be orphaned", principal(f.A), before) + }) + t.Run("the install request's role is the agent's, and no person's: the owner and the organisation administrator hold no role", func(t *testing.T) { + byAgent := sp.must(t)(sp.agent(t, A, binding)) + wc := msg(byAgent, "context") + exactOwnedScope(t, wc, f.Kind, []string{"create", "read", "write"}, U(t)) + if str(wc, "owner_principal_kind") != "agent" || str(wc, "owner_principal_id") != U(t) { + t.Fatalf("the installation acting for itself is %s (%s), want the solution principal %s", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind"), U(t)) + } + _, err := sp.agent(t, A, binding, sp.owned(f.Kind, []string{"admin"}, U(t))) + refused(t, err, codes.PermissionDenied, "beyond") + _, err = sp.agent(t, A, "") + refused(t, err, codes.PermissionDenied, "binding") + _, err = sp.root(t, f.Admin, A) + refused(t, err, codes.PermissionDenied, "holds no role") + if got := sp.roleGrants(t, A); len(got) != 0 { + t.Fatalf("an installation nobody was granted lists %v", got) } }) t.Run("without a role on the solution a person is refused, naming the reason", func(t *testing.T) { - _, err := root(t, f.Viewer, f.A) + _, err := sp.root(t, f.Member, A) refused(t, err, codes.PermissionDenied, "holds no role") }) - t.Run("a viewer reads what the principal owns, and nothing another installation or a person owns", func(t *testing.T) { - grant(t, f.A, "viewer", f.Viewer) - out := must(t)(through(t, f.Viewer, f.A)) - exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, principal(f.A)) + t.Run("a viewer reads exactly what the principal owns, and nothing a person, an installation or another principal owns", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + root := sp.must(t)(sp.root(t, f.Viewer, A)) + exactOwnedScope(t, msg(root, "context"), A.Solution+".note", []string{"read"}, U(t)) + out := sp.must(t)(sp.through(t, f.Viewer, A, binding)) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, U(t)) c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.Store}) - listed := c.must(t, ctx, "host.authority.v1.AuthorityService", "ListResources", map[string]any{"token": str(out, "token"), "audience": f.Store, "resource_kind": f.Kind, "action": "read"}) - grants := listMsgs(listed, "grants") - if len(grants) != 1 || str(grants[0], "owner") != principal(f.A) { - t.Fatalf("listed %v, want exactly the resources principal %s owns", grants, principal(f.A)) - } - // Owner and ids together narrow within the owner: both must match. - narrowed := must(t)(through(t, f.Viewer, f.A, owned([]string{"read"}, principal(f.A), "u-doc"))) - exactOwnedScopeIDs(t, msg(narrowed, "context"), f.Kind, []string{"read"}, principal(f.A), []string{"u-doc"}) + sp.listed(t, out, f.Kind, "read", map[string]string{"*": U(t)}) + narrowed := sp.must(t)(sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"read"}, U(t), "u-doc"))) + exactOwnedScopeIDs(t, msg(narrowed, "context"), f.Kind, []string{"read"}, U(t), []string{"u-doc"}) + sp.listed(t, narrowed, f.Kind, "read", map[string]string{"u-doc": U(t)}) for name, ask := range map[string]any{ - "another installation's data": owned([]string{"read"}, principal(f.B)), - "another owner's ids": owned([]string{"read"}, principal(f.B), "u-doc"), - "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), - "a resource it does not own": scopeAsk(f.Kind, []string{"read"}, []string{f.Existing}), + "another installation's principal": sp.owned(f.Kind, []string{"read"}, V(t)), + "another principal's ids": sp.owned(f.Kind, []string{"read"}, V(t), "u-doc"), + "another organisation's principal": sp.owned(f.Kind, []string{"read"}, sp.principal(t, f.OtherOrganization, A)), + "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), + "a resource by id alone": scopeAsk(f.Kind, []string{"read"}, []string{f.Existing}), + "a person as owner": sp.owned(f.Kind, []string{"read"}, sp.personID(t, f.Viewer)), + "the installation id as owner": sp.owned(f.Kind, []string{"read"}, str(sp.ensure(t, f.Organization, A), "id")), + "the organisation as owner": sp.owned(f.Kind, []string{"read"}, f.Organization), } { - _, err := through(t, f.Viewer, f.A, ask) + _, err := sp.through(t, f.Viewer, A, binding, ask) t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) } }) + t.Run("a capability through the solution is confined to the principal's data; the person's own grants never flow in", func(t *testing.T) { + for _, token := range []string{f.Reader, f.Administrator} { + sp.grant(t, A, "viewer", sp.person(t, token)) + out := sp.must(t)(sp.through(t, token, A, binding)) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, U(t)) + } + _, err := sp.through(t, f.Reader, A, binding, scopeAsk(f.Kind, []string{"read"}, []string{f.Existing})) + refused(t, err, codes.PermissionDenied, "confined") + _, err = sp.through(t, f.Administrator, A, binding, scopeAsk(f.Kind, []string{"admin"}, []string{f.Existing})) + refused(t, err, codes.PermissionDenied, "confined") + }) + t.Run("owner-selected scopes are issued only to an audience that checks owners", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + _, err := sp.through(t, f.Viewer, A, legacy) + refused(t, err, codes.PermissionDenied, "owner-aware") + _, err = sp.agent(t, A, legacy) + refused(t, err, codes.PermissionDenied, "owner-aware") + }) t.Run("a resource created through the solution is owned by its principal, the person its author", func(t *testing.T) { - grant(t, f.A, "editor", f.Editor) - out := must(t)(through(t, f.Editor, f.A, owned([]string{"create"}, principal(f.A)))) + sp.grant(t, A, "editor", sp.person(t, f.Editor)) + out := sp.must(t)(sp.through(t, f.Editor, A, binding, sp.owned(f.Kind, []string{"create"}, U(t)))) wc := msg(out, "context") - exactOwnedScope(t, wc, f.Kind, []string{"create"}, principal(f.A)) - if str(wc, "owner_principal_id") != personID(t, f.Editor) || str(wc, "owner_principal_kind") != "human" { + exactOwnedScope(t, wc, f.Kind, []string{"create"}, U(t)) + if str(wc, "owner_principal_id") != sp.personID(t, f.Editor) || str(wc, "owner_principal_kind") != "human" { t.Fatalf("the author is %s (%s), want the person", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind")) } - // The installation acting for itself is the principal: owner and - // author are both U, and it creates for U. - byAgent := must(t)(agent(f.A, owned([]string{"create"}, principal(f.A)))) - exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create"}, principal(f.A)) - if wc := msg(byAgent, "context"); str(wc, "owner_principal_kind") != "agent" || str(wc, "owner_principal_id") != principal(f.A) { - t.Fatalf("the installation acting for itself is %s (%s), want the solution principal %s", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind"), principal(f.A)) - } - }) - t.Run("the install request's role is the agent's role, and no person's", func(t *testing.T) { - // A was installed with role editor: the agent holds exactly editor's - // level on the principal's data, and not installer's admin. - byAgent := must(t)(agent(f.A)) - exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create", "read", "write"}, principal(f.A)) - _, err := agent(f.A, owned([]string{"admin"}, principal(f.A))) - refused(t, err, codes.PermissionDenied, "beyond") - // Nobody inherits it: a team member is refused until the team is granted. - _, err = root(t, f.TeamMember, f.A) - refused(t, err, codes.PermissionDenied, "holds no role") + byAgent := sp.must(t)(sp.agent(t, A, binding, sp.owned(f.Kind, []string{"create"}, U(t)))) + exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create"}, U(t)) }) t.Run("an editor writes and a viewer cannot, each by name", func(t *testing.T) { - out := must(t)(through(t, f.Editor, f.A, owned([]string{"write"}, principal(f.A)))) - exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"write"}, principal(f.A)) - _, err := through(t, f.Viewer, f.A, owned([]string{"write"}, principal(f.A))) + sp.grant(t, A, "editor", sp.person(t, f.Editor)) + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + out := sp.must(t)(sp.through(t, f.Editor, A, binding, sp.owned(f.Kind, []string{"write"}, U(t)))) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"write"}, U(t)) + _, err := sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"write"}, U(t))) refused(t, err, codes.PermissionDenied, "widens") }) - t.Run("another installation's members never reach the principal's data", func(t *testing.T) { - grant(t, f.B, "viewer", f.Member) - _, err := root(t, f.Member, f.A) + t.Run("an organisation administrator or the solution's installer grants; anyone else, and every malformed grant, is refused by name", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + sp.grant(t, A, "installer", sp.person(t, f.Installer)) + // The installer role grants and revokes on its own installation. + sp.roleAs(t, f.Installer, "GrantRole", f.Organization, A, "viewer", sp.person(t, f.Member)) + sp.roleAs(t, f.Installer, "RevokeRole", f.Organization, A, "viewer", sp.person(t, f.Member)) + inst := str(sp.ensure(t, f.Organization, A), "id") + for name, r := range map[string]struct { + caller string + fields map[string]any + code codes.Code + reason string + }{ + "a viewer granting itself installer": {f.Viewer, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "installer", "person_id": sp.personID(t, f.Viewer)}, codes.PermissionDenied, "may not grant"}, + "another organisation's id": {f.Admin, map[string]any{"organization_id": f.OtherOrganization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.Member)}, codes.PermissionDenied, "organization"}, + "an undeclared role": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "owner", "person_id": sp.personID(t, f.Member)}, codes.NotFound, "declares no role"}, + "both a person and a team": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.Member), "team_id": f.Team}, codes.InvalidArgument, "exactly one"}, + "neither a person nor a team": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "viewer"}, codes.InvalidArgument, "exactly one"}, + "a person of another organisation": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.OtherMember)}, codes.PermissionDenied, "not a member"}, + } { + t.Run(name, func(t *testing.T) { + _, err := c.call(sp.as(t, r.caller), "host.installation.v1.InstallationService", "GrantRole", r.fields) + refused(t, err, r.code, r.reason) + }) + } + _, err := c.call(sp.as(t, f.Viewer), "host.installation.v1.InstallationService", "RevokeRole", map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "installer", "person_id": sp.personID(t, f.Installer)}) + refused(t, err, codes.PermissionDenied, "may not grant") + _, err = c.call(sp.as(t, f.Viewer), "host.installation.v1.InstallationService", "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": inst}) + refused(t, err, codes.PermissionDenied, "may not") + }) + t.Run("another installation's and another organisation's members never reach the principal's data", func(t *testing.T) { + sp.grant(t, B, "viewer", sp.person(t, f.Member)) + _, err := sp.root(t, f.Member, A) refused(t, err, codes.PermissionDenied, "holds no role") - out := must(t)(through(t, f.Member, f.B)) - exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, principal(f.B)) - _, err = through(t, f.Member, f.B, owned([]string{"read"}, principal(f.A))) + out := sp.must(t)(sp.through(t, f.Member, B, binding)) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, V(t)) + _, err = sp.through(t, f.Member, B, binding, sp.owned(f.Kind, []string{"read"}, U(t))) + refused(t, err, codes.PermissionDenied, "widens") + other := sp.principal(t, f.OtherOrganization, A) + sp.roleIn(t, f.OtherAdmin, f.OtherOrganization, A, "viewer", sp.person(t, f.OtherMember)) + elsewhere := sp.must(t)(sp.through(t, f.OtherMember, A, binding)) + exactOwnedScope(t, msg(elsewhere, "context"), f.Kind, []string{"read"}, other) + _, err = sp.through(t, f.OtherMember, A, binding, sp.owned(f.Kind, []string{"read"}, U(t))) refused(t, err, codes.PermissionDenied, "widens") }) - t.Run("a child never widens the owner or the actions it narrowed to", func(t *testing.T) { - narrowed := must(t)(through(t, f.Viewer, f.A, owned([]string{"read"}, principal(f.A)))) - next := must(t)(hop(narrowed)) - exactOwnedScope(t, msg(next, "context"), f.Kind, []string{"read"}, principal(f.A)) - for name, ask := range map[string]any{ - "another owner": owned([]string{"read"}, principal(f.B)), - "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), + t.Run("a child never widens the owner, the actions or the ids it narrowed to", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + sp.grant(t, A, "editor", sp.person(t, f.Editor)) + narrowed := sp.must(t)(sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"read"}, U(t)))) + exactOwnedScope(t, msg(sp.must(t)(sp.hop(t, narrowed)), "context"), f.Kind, []string{"read"}, U(t)) + readOnly := sp.must(t)(sp.through(t, f.Editor, A, binding, sp.owned(f.Kind, []string{"read"}, U(t)))) + oneDoc := sp.must(t)(sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"read"}, U(t), "u-doc"))) + exactOwnedScopeIDs(t, msg(sp.must(t)(sp.hop(t, oneDoc)), "context"), f.Kind, []string{"read"}, U(t), []string{"u-doc"}) + for name, attempt := range map[string]struct { + parent protoreflect.Message + ask any + }{ + "another owner": {narrowed, sp.owned(f.Kind, []string{"read"}, V(t))}, + "the whole kind": {narrowed, scopeAsk(f.Kind, []string{"read"}, nil)}, + "an action it dropped": {readOnly, sp.owned(f.Kind, []string{"write"}, U(t))}, + "the ids it narrowed to": {oneDoc, sp.owned(f.Kind, []string{"read"}, U(t))}, } { - _, err := hop(narrowed, ask) + _, err := sp.hop(t, attempt.parent, attempt.ask) t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) } - readOnly := must(t)(through(t, f.Editor, f.A, owned([]string{"read"}, principal(f.A)))) - _, err := hop(readOnly, owned([]string{"write"}, principal(f.A))) - refused(t, err, codes.PermissionDenied, "widens") - oneDoc := must(t)(through(t, f.Viewer, f.A, owned([]string{"read"}, principal(f.A), "u-doc"))) - exactOwnedScopeIDs(t, msg(must(t)(hop(oneDoc)), "context"), f.Kind, []string{"read"}, principal(f.A), []string{"u-doc"}) - _, err = hop(oneDoc, owned([]string{"read"}, principal(f.A))) - refused(t, err, codes.PermissionDenied, "widens") }) - t.Run("an existing resource joins the principal only by a transfer its person administers through their own grants", func(t *testing.T) { - grant(t, f.A, "viewer", f.Administrator) - grant(t, f.A, "viewer", f.Reader) - administers := must(t)(through(t, f.Administrator, f.A)) - out := must(t)(transfer(str(administers, "token"), principal(f.A))) - if str(out, "principal_id") != principal(f.A) { - t.Fatalf("transferred to %s, want %s", str(out, "principal_id"), principal(f.A)) - } - _, err := transfer(str(administers, "token"), principal(f.B)) - refused(t, err, codes.PermissionDenied, "issued through") - reads := must(t)(through(t, f.Reader, f.A)) - _, err = transfer(str(reads, "token"), principal(f.A)) - refused(t, err, codes.PermissionDenied, "own organisation grants") - // The installer's role administers the principal's data, not Existing. - installer := must(t)(through(t, f.Admin, f.A)) - _, err = transfer(str(installer, "token"), principal(f.A)) - refused(t, err, codes.PermissionDenied, "own organisation grants") - byAgent := must(t)(agent(f.A)) - _, err = transfer(str(byAgent, "token"), principal(f.A)) - refused(t, err, codes.PermissionDenied, "a person's act") + t.Run("an existing resource joins the principal only by a transfer from a person or the organisation, administered through the person's own grants, by an editor of the principal", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Administrator)) + asViewer := sp.must(t)(sp.through(t, f.Administrator, A, binding)) + _, err := sp.transfer(t, asViewer, f.StoreCredential, f.Existing, f.Organization, U(t)) + refused(t, err, codes.PermissionDenied, "editor") + sp.grant(t, A, "editor", sp.person(t, f.Administrator)) + administers := sp.must(t)(sp.through(t, f.Administrator, A, binding)) + for from, owner := range map[string]string{"the organisation": f.Organization, "a person": sp.personID(t, f.Administrator)} { + out, err := sp.transfer(t, administers, f.StoreCredential, f.Existing, owner, U(t)) + t.Run("from "+from, func(t *testing.T) { + if err != nil { + t.Fatal(err) + } + if str(out, "principal_id") != U(t) || str(out, "authorized_by") != sp.personID(t, f.Administrator) { + t.Fatalf("transferred to %s by %s, want %s by the person", str(out, "principal_id"), str(out, "authorized_by"), U(t)) + } + }) + } + sp.grant(t, A, "editor", sp.person(t, f.Reader)) + sp.grant(t, A, "installer", sp.person(t, f.Installer)) + reads := sp.must(t)(sp.through(t, f.Reader, A, binding)) + installs := sp.must(t)(sp.through(t, f.Installer, A, binding)) + byAgent := sp.must(t)(sp.agent(t, A, binding)) + atRoot := sp.must(t)(sp.root(t, f.Administrator, A)) + for name, r := range map[string]struct { + capability protoreflect.Message + actor, id, from, to string + code codes.Code + reason string + }{ + "from another solution principal": {administers, f.StoreCredential, f.Existing, V(t), U(t), codes.PermissionDenied, "solution principal"}, + "to a principal the capability is not through": {administers, f.StoreCredential, f.Existing, f.Organization, V(t), codes.PermissionDenied, "issued through"}, + "by a reader of the resource": {reads, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "own organisation grants"}, + "by the installer role alone": {installs, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "own organisation grants"}, + "by the installation's agent": {byAgent, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "a person's act"}, + "asked by a module that does not serve the kind": {administers, f.A.Credential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "does not serve"}, + "with a capability addressed elsewhere": {atRoot, f.StoreCredential, f.Existing, f.Organization, U(t), codes.Unauthenticated, "audience"}, + "of the wildcard id": {administers, f.StoreCredential, "*", f.Organization, U(t), codes.PermissionDenied, "not a usable id"}, + "of an oversize id": {administers, f.StoreCredential, strings.Repeat("x", 129), f.Organization, U(t), codes.PermissionDenied, "not a usable id"}, + } { + _, err := sp.transfer(t, r.capability, r.actor, r.id, r.from, r.to) + t.Run(name, func(t *testing.T) { refused(t, err, r.code, r.reason) }) + } }) - t.Run("revoking a person's role stops access at once; re-granting restores it", func(t *testing.T) { - before := must(t)(through(t, f.Viewer, f.A)) - role(t, "RevokeRole", f.A, "viewer", person(t, f.Viewer)) - refused(t, check(before), codes.PermissionDenied, "") - _, err := root(t, f.Viewer, f.A) + t.Run("revoking a person's role stops their access at once, theirs alone; their old capabilities stay dead after a re-grant", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + sp.grant(t, A, "editor", sp.person(t, f.Editor)) + before := sp.must(t)(sp.through(t, f.Viewer, A, binding)) + unrelated := sp.must(t)(sp.through(t, f.Editor, A, binding)) + if !slices.Contains(sp.roleGrants(t, A), "viewer person "+sp.personID(t, f.Viewer)) { + t.Fatalf("ListRoleGrants does not show the grant: %v", sp.roleGrants(t, A)) + } + sp.revoke(t, A, "viewer", sp.person(t, f.Viewer)) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + _, err := sp.hop(t, before) + refused(t, err, codes.PermissionDenied, "revoked") + _, err = sp.root(t, f.Viewer, A) refused(t, err, codes.PermissionDenied, "holds no role") - grant(t, f.A, "viewer", f.Viewer) - exactOwnedScope(t, msg(must(t)(through(t, f.Viewer, f.A)), "context"), f.Kind, []string{"read"}, principal(f.A)) + if err := sp.check(unrelated); err != nil { + t.Fatalf("revoking one person's role invalidated another's capability: %v", err) + } + if slices.Contains(sp.roleGrants(t, A), "viewer person "+sp.personID(t, f.Viewer)) { + t.Fatal("ListRoleGrants still shows a revoked grant") + } + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + exactOwnedScope(t, msg(sp.must(t)(sp.through(t, f.Viewer, A, binding)), "context"), f.Kind, []string{"read"}, U(t)) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") }) - t.Run("a team's role is its members' access; revoking it stops them at once; re-granting restores it", func(t *testing.T) { - role(t, "GrantRole", f.A, "viewer", team) - before := must(t)(through(t, f.TeamMember, f.A)) - exactOwnedScope(t, msg(before, "context"), f.Kind, []string{"read"}, principal(f.A)) - role(t, "RevokeRole", f.A, "viewer", team) - refused(t, check(before), codes.PermissionDenied, "") - _, err := root(t, f.TeamMember, f.A) + t.Run("a team's role is its members' access; revoking it or leaving the team stops them at once; old capabilities stay dead after a re-grant", func(t *testing.T) { + team := map[string]any{"team_id": f.Team} + sp.grant(t, A, "viewer", team) + if !slices.Contains(sp.roleGrants(t, A), "viewer team "+f.Team) { + t.Fatalf("ListRoleGrants does not show the team grant: %v", sp.roleGrants(t, A)) + } + before := sp.must(t)(sp.through(t, f.TeamMember, A, binding)) + exactOwnedScope(t, msg(before, "context"), f.Kind, []string{"read"}, U(t)) + sp.revoke(t, A, "viewer", team) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + _, err := sp.root(t, f.TeamMember, A) + refused(t, err, codes.PermissionDenied, "holds no role") + sp.grant(t, A, "viewer", team) + sp.must(t)(sp.through(t, f.TeamMember, A, binding)) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + leaving := sp.must(t)(sp.through(t, f.TeamLeaver, A, binding)) + if err := f.RemoveFromTeam(ctx, sp.personID(t, f.TeamLeaver), f.Team); err != nil { + t.Fatal(err) + } + refused(t, sp.check(leaving), codes.PermissionDenied, "revoked") + _, err = sp.root(t, f.TeamLeaver, A) refused(t, err, codes.PermissionDenied, "holds no role") - role(t, "GrantRole", f.A, "viewer", team) - exactOwnedScope(t, msg(must(t)(through(t, f.TeamMember, f.A)), "context"), f.Kind, []string{"read"}, principal(f.A)) }) - t.Run("uninstalling disables the principal at once and its data stays owned; a re-grant after reinstall restores access", func(t *testing.T) { - before := must(t)(through(t, f.Viewer, f.A)) - u := principal(f.A) - if err := f.Uninstall(ctx, str(installed[f.A.Solution], "id")); err != nil { + t.Run("a person leaving the organisation loses the solution's data at once", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.OrgLeaver)) + before := sp.must(t)(sp.through(t, f.OrgLeaver, A, binding)) + if err := f.RemoveFromOrganization(ctx, sp.personID(t, f.OrgLeaver)); err != nil { t.Fatal(err) } - refused(t, check(before), codes.PermissionDenied, "") - _, err := agent(f.A) - refused(t, err, codes.PermissionDenied, "") - if install(t, f.A); principal(f.A) != u { - t.Fatalf("reinstalled as %s, want %s", principal(f.A), u) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + }) + t.Run("uninstalling disables the principal at once and its data stays owned; old capabilities stay dead after reinstall and re-grant", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + sp.grant(t, A, "editor", sp.person(t, f.Administrator)) + before := sp.must(t)(sp.through(t, f.Viewer, A, binding)) + parent := sp.must(t)(sp.root(t, f.Viewer, A)) + administers := sp.must(t)(sp.through(t, f.Administrator, A, binding)) + u := U(t) + sp.uninstall(t, f.Organization, A, func() { + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + _, err := sp.child(t, parent, A, binding) + refused(t, err, codes.PermissionDenied, "revoked") + _, err = sp.transfer(t, administers, f.StoreCredential, f.Existing, f.Organization, u) + refused(t, err, codes.PermissionDenied, "revoked") + _, err = sp.agent(t, A, binding) + refused(t, err, codes.FailedPrecondition, "not installed") + _, err = sp.root(t, f.Viewer, A) + refused(t, err, codes.FailedPrecondition, "not installed") + }) + if U(t) != u { + t.Fatalf("reinstalled as %s, want %s", U(t), u) } - _, err = root(t, f.Viewer, f.A) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + _, err := sp.root(t, f.Viewer, A) refused(t, err, codes.PermissionDenied, "holds no role") - grant(t, f.A, "viewer", f.Viewer) - out := must(t)(through(t, f.Viewer, f.A)) - exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, u) + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + exactOwnedScope(t, msg(sp.must(t)(sp.through(t, f.Viewer, A, binding)), "context"), f.Kind, []string{"read"}, u) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") }) } -// missingContract names each target element the published descriptors lack. -func (c *client) missingContract() []string { - var missing []string - for _, want := range solutionPrincipalContract { - d, err := c.files.FindDescriptorByName(protoreflect.FullName(want.message)) +// principalSuite holds the target's sessions and installations, so each case +// makes its own grants and can run alone. +type principalSuite struct { + c *client + f SolutionPrincipalCase + ctx context.Context + sessions map[string]protoreflect.Message + installed map[string]protoreflect.Message // organisation/solution → installation +} + +func (sp *principalSuite) session(t *testing.T, token string) protoreflect.Message { + t.Helper() + if s, ok := sp.sessions[token]; ok { + return s + } + s := sp.c.must(t, sp.ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": token}) + sp.sessions[token] = s + return s +} + +func (sp *principalSuite) personID(t *testing.T, token string) string { + return str(sp.session(t, token), "person_id") +} +func (sp *principalSuite) as(t *testing.T, token string) context.Context { + return as(sp.ctx, sp.session(t, token)) +} +func (sp *principalSuite) person(t *testing.T, token string) map[string]any { + return map[string]any{"person_id": sp.personID(t, token)} +} + +// admin is the administrator of org who installs there. +func (sp *principalSuite) admin(org string) string { + if org == sp.f.OtherOrganization { + return sp.f.OtherAdmin + } + return sp.f.Admin +} + +// ensure installs s at org once, with the agent's role editor. +func (sp *principalSuite) ensure(t *testing.T, org string, s SolutionUnderTest) protoreflect.Message { + t.Helper() + if inst, ok := sp.installed[org+"/"+s.Solution]; ok { + return inst + } + admin := sp.admin(org) + out := sp.c.must(t, sp.as(t, admin), "host.installation.v1.InstallationService", "Install", map[string]any{ + "organization_id": org, "target": s.Solution, "role": "editor", "owner_person_id": sp.personID(t, admin)}) + if enumName(out, "status") != "STATUS_ACTIVE" { + t.Fatalf("%s at %s is %s; the host's install step must converge for the suite's solutions", s.Solution, org, enumName(out, "status")) + } + sp.installed[org+"/"+s.Solution] = out + return out +} + +func (sp *principalSuite) principal(t *testing.T, org string, s SolutionUnderTest) string { + return str(sp.ensure(t, org, s), "principal_id") +} + +// uninstall runs the host's uninstall, then each check while it is disabled, +// then reinstalls through the interface. +func (sp *principalSuite) uninstall(t *testing.T, org string, s SolutionUnderTest, whileDisabled ...func()) { + t.Helper() + if err := sp.f.Uninstall(sp.ctx, str(sp.ensure(t, org, s), "id")); err != nil { + t.Fatal(err) + } + delete(sp.installed, org+"/"+s.Solution) + for _, check := range whileDisabled { + check() + } + sp.ensure(t, org, s) +} + +func (sp *principalSuite) roleAs(t *testing.T, caller, method, org string, s SolutionUnderTest, role string, subject map[string]any) { + t.Helper() + request := map[string]any{"organization_id": org, "installation_id": str(sp.ensure(t, org, s), "id"), "role": role} + for k, v := range subject { + request[k] = v + } + sp.c.must(t, sp.as(t, caller), "host.installation.v1.InstallationService", method, request) +} +func (sp *principalSuite) grant(t *testing.T, s SolutionUnderTest, role string, subject map[string]any) { + t.Helper() + sp.roleAs(t, sp.f.Admin, "GrantRole", sp.f.Organization, s, role, subject) +} +func (sp *principalSuite) revoke(t *testing.T, s SolutionUnderTest, role string, subject map[string]any) { + t.Helper() + sp.roleAs(t, sp.f.Admin, "RevokeRole", sp.f.Organization, s, role, subject) +} +func (sp *principalSuite) roleIn(t *testing.T, caller, org string, s SolutionUnderTest, role string, subject map[string]any) { + t.Helper() + sp.roleAs(t, caller, "GrantRole", org, s, role, subject) +} + +// roleGrants reads ListRoleGrants as " person " or " team ". +func (sp *principalSuite) roleGrants(t *testing.T, s SolutionUnderTest) []string { + t.Helper() + out := sp.c.must(t, sp.as(t, sp.f.Admin), "host.installation.v1.InstallationService", "ListRoleGrants", map[string]any{ + "organization_id": sp.f.Organization, "installation_id": str(sp.ensure(t, sp.f.Organization, s), "id")}) + var grants []string + for _, g := range listMsgs(out, "grants") { + if id := str(g, "person_id"); id != "" { + grants = append(grants, str(g, "role")+" person "+id) + } else { + grants = append(grants, str(g, "role")+" team "+str(g, "team_id")) + } + } + return grants +} + +func (sp *principalSuite) issue(fields map[string]any) (protoreflect.Message, error) { + return sp.c.call(sp.ctx, "host.authority.v1.AuthorityService", "Issue", fields) +} + +// root is a person's capability at s in their own organisation. +func (sp *principalSuite) root(t *testing.T, token string, s SolutionUnderTest) (protoreflect.Message, error) { + return sp.c.call(sp.as(t, token), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": s.Solution}) +} + +// child is s's hop on parent through binding, asking scopes. +func (sp *principalSuite) child(t *testing.T, parent protoreflect.Message, s SolutionUnderTest, binding string, scopes ...any) (protoreflect.Message, error) { + audience := sp.f.Store + if binding == "legacy" { + audience = sp.f.LegacyStore + } + return sp.issue(map[string]any{"parent_token": str(parent, "token"), "actor": map[string]any{"credential": s.Credential}, + "operation_binding_id": binding, "audience": audience, "scopes": scopes}) +} + +// through is a person at s, then s's hop through binding. +func (sp *principalSuite) through(t *testing.T, token string, s SolutionUnderTest, binding string, scopes ...any) (protoreflect.Message, error) { + t.Helper() + r, err := sp.root(t, token, s) + if err != nil { + return nil, err + } + return sp.child(t, r, s, binding, scopes...) +} + +// hop is the store's own hop on parent, through its self binding. +func (sp *principalSuite) hop(t *testing.T, parent protoreflect.Message, scopes ...any) (protoreflect.Message, error) { + return sp.issue(map[string]any{"parent_token": str(parent, "token"), "actor": map[string]any{"credential": sp.f.StoreCredential}, + "operation_binding_id": "self", "audience": sp.f.Store, "scopes": scopes}) +} + +// agent is the installation of s acting for itself through binding ("" names none). +func (sp *principalSuite) agent(t *testing.T, s SolutionUnderTest, binding string, scopes ...any) (protoreflect.Message, error) { + audience := sp.f.Store + if binding == "legacy" { + audience = sp.f.LegacyStore + } + fields := map[string]any{"installation": map[string]any{"installation_id": str(sp.ensure(t, sp.f.Organization, s), "id")}, + "actor": map[string]any{"credential": s.Credential}, "audience": audience, "scopes": scopes} + if binding != "" { + fields["operation_binding_id"] = binding + } + return sp.issue(fields) +} + +func (sp *principalSuite) owned(kind string, actions []string, owner string, ids ...string) map[string]any { + ask := scopeAsk(kind, actions, ids) + ask["resource_owner_id"] = owner + return ask +} + +func (sp *principalSuite) check(capability protoreflect.Message) error { + _, err := sp.c.call(sp.ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(capability, "token"), "audience": sp.f.Store}) + return err +} + +func (sp *principalSuite) transfer(t *testing.T, capability protoreflect.Message, actor, id, from, to string) (protoreflect.Message, error) { + return sp.c.call(sp.ctx, "host.authority.v1.AuthorityService", "AuthorizeTransfer", map[string]any{"token": str(capability, "token"), + "actor": map[string]any{"credential": actor}, "resource_kind": sp.f.Kind, "resource_id": id, "from_owner_id": from, "to_principal_id": to}) +} + +// listed requires ListResources to answer exactly want: id ("*" for every +// resource) → its owner. +func (sp *principalSuite) listed(t *testing.T, capability protoreflect.Message, kind, action string, want map[string]string) { + t.Helper() + out := sp.c.must(t, sp.ctx, "host.authority.v1.AuthorityService", "ListResources", map[string]any{"token": str(capability, "token"), "audience": sp.f.Store, "resource_kind": kind, "action": action}) + got := map[string]string{} + for _, g := range listMsgs(out, "grants") { + key := str(g, "id") + if field(g, "all").IsValid() && field(g, "all").Bool() { + key = "*" + } + if key == "" || str(g, "prefix") != "" || str(g, "pattern") != "" { + t.Fatalf("an owner-selected capability listed %v", g) + } + got[key] = str(g, "resource_owner_id") + } + if len(got) != len(want) { + t.Fatalf("listed %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("listed %v, want %v", got, want) + } + } +} + +// must(t)(call()) stops the case at an unexpected refusal. +func (sp *principalSuite) must(t *testing.T) func(protoreflect.Message, error) protoreflect.Message { + return func(out protoreflect.Message, err error) protoreflect.Message { + t.Helper() if err != nil { - if !slices.Contains(missing, want.message) { - missing = append(missing, want.message) - } - continue + t.Fatal(err) } - switch d := d.(type) { - case protoreflect.MessageDescriptor: - if d.Fields().ByName(protoreflect.Name(want.element)) == nil { - missing = append(missing, want.message+"."+want.element) + return out + } +} + +// contractProblems names each target element the descriptors lack, each +// element they still carry that the target deletes, and an owner selector on +// ResourceGrant that would sit inside its match oneof (owner and id must both +// be expressible). +func (c *client) contractProblems() []string { + var lacks, keeps []string + for _, want := range solutionPrincipalContract { + if !c.hasElement(want.message, want.element) { + name := want.message + if c.hasElement(want.message, "") { + name += "." + want.element } - case protoreflect.ServiceDescriptor: - if d.Methods().ByName(protoreflect.Name(want.element)) == nil { - missing = append(missing, want.message+"/"+want.element) + if !slices.Contains(lacks, name) { + lacks = append(lacks, name) } } } - return missing + for _, gone := range solutionPrincipalRemoved { + if c.hasElement(gone.message, gone.element) { + keeps = append(keeps, strings.TrimSuffix(gone.message+"."+gone.element, ".")) + } + } + var problems []string + if len(lacks) > 0 { + problems = append(problems, "lacks "+strings.Join(lacks, ", ")) + } + if len(keeps) > 0 { + problems = append(problems, "still carries "+strings.Join(keeps, ", ")+" (deleted by the target)") + } + if d, err := c.files.FindDescriptorByName("host.authority.v1.ResourceGrant"); err == nil { + if fd := d.(protoreflect.MessageDescriptor).Fields().ByName("resource_owner_id"); fd != nil && fd.ContainingOneof() != nil { + problems = append(problems, "puts ResourceGrant.resource_owner_id inside a oneof; it qualifies a match") + } + } + if d, err := c.files.FindDescriptorByName("host.authority.v1.WorkScopeV1"); err == nil { + if fd := d.(protoreflect.MessageDescriptor).Fields().ByName("resource_owner_id"); fd != nil && fd.Number() != 4 { + problems = append(problems, "numbers WorkScopeV1.resource_owner_id other than 4 (core's field)") + } + } + return problems +} + +// hasElement reports whether message (or service) exists and, when element is +// named, has that field (or method). +func (c *client) hasElement(name, element string) bool { + d, err := c.files.FindDescriptorByName(protoreflect.FullName(name)) + if err != nil { + return false + } + if element == "" { + return true + } + switch d := d.(type) { + case protoreflect.MessageDescriptor: + return d.Fields().ByName(protoreflect.Name(element)) != nil + case protoreflect.ServiceDescriptor: + return d.Methods().ByName(protoreflect.Name(element)) != nil + } + return false +} + +// installationPrincipal is the principal an installation acts as for itself: +// principal_id under the target contract, agent_principal_id before it. Run +// reads it so one host passes both Run and the target. +func installationPrincipal(installation protoreflect.Message) string { + if id := str(installation, "principal_id"); id != "" { + return id + } + return str(installation, "agent_principal_id") } // exactOwnedScope requires the effective scope to be exactly kind and actions diff --git a/libraries/hop/go/owner_target_test.go b/libraries/hop/go/owner_target_test.go new file mode 100644 index 00000000..869d57fa --- /dev/null +++ b/libraries/hop/go/owner_target_test.go @@ -0,0 +1,52 @@ +package hop_test + +import ( + "context" + "os" + "slices" + "testing" + + "github.com/codefly-dev/authority/libraries/hop/go/hoptest" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" +) + +// ownedScope is {kind, [action], resource_owner_id: owner, resource_ids: ids}, +// with the owner selector written as WorkScopeV1 field 4: the target's field, +// carried as an unknown field until core declares it. +func ownedScope(t *testing.T, kind, action, owner string, ids ...string) *basev0.WorkScopeV1 { + t.Helper() + raw, err := proto.Marshal(&basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}, ResourceIds: ids}) + if err != nil { + t.Fatal(err) + } + raw = protowire.AppendString(protowire.AppendTag(raw, 4, protowire.BytesType), owner) + out := &basev0.WorkScopeV1{} + if err := proto.Unmarshal(raw, out); err != nil { + t.Fatal(err) + } + return out +} + +// TARGET (solution principal; conformance RunSolutionPrincipalTarget): run +// with AUTHORITY_TARGET=solution-principal. A module fronted by the hop must +// never read an owner-selected scope as the whole kind: the stamp leaves it +// out, and the authenticated claims admit a resource only with its owner. +func TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind(t *testing.T) { + if os.Getenv("AUTHORITY_TARGET") != "solution-principal" { + t.Skip("target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal") + } + a := hoptest.New(t) + token := a.Issue(t, "documents", ownedScope(t, "documents", "read", "principal-u")) + id, err := verifier(t, a, "documents").Verify(context.Background(), hoptest.Forward(t, token, proof)) + if err != nil { + t.Fatalf("target: a capability with an owner-selected scope must verify: %v", err) + } + if id.Has("documents", "read") || slices.Contains(id.Scopes, "documents:read") { + t.Fatalf("target: the hop stamped an owner-selected scope as whole-kind documents:read (%v)", id.Scopes) + } + if id.Claims.Allows("documents", "read", "") || id.Claims.Allows("documents", "read", "any") { + t.Fatal("target: Claims.Allows admits an owner-selected scope without the resource's owner") + } +} diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index b73112c2..796c8a34 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -279,7 +279,7 @@ func Bare(t *testing.T) *Kit { // manifests from their own workloads. // robin and assistant are declared for the artifact tests, which compose // them themselves. - for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-store"} { + for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-store", "principal-legacy-store"} { launcher.Declare(Workload(module)) } _, signing, err := ed25519.GenerateKey(rand.Reader) diff --git a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go index 2847f317..9d5b9b64 100644 --- a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go @@ -6,6 +6,7 @@ package registry_test import ( "context" + "errors" "net" "os" "path/filepath" @@ -44,22 +45,18 @@ func TestTheLocalAuthorityMeetsTheSolutionPrincipalTarget(t *testing.T) { scopes: - {kind: principal.collection, actions: [read], resources: [{id: store-x}]} people: - - id: store-administrator - fixture_token: fixture-store-administrator - organization: acme - roles: [principal-store-admin] - - id: store-reader - fixture_token: fixture-store-reader - organization: acme - roles: [principal-store-reader] - - id: principal-team-member - fixture_token: fixture-principal-team-member - organization: acme - roles: [] - teams: [principal-team] + - {id: store-administrator, fixture_token: fixture-store-administrator, organization: acme, roles: [principal-store-admin]} + - {id: store-reader, fixture_token: fixture-store-reader, organization: acme, roles: [principal-store-reader]} + - {id: principal-installer, fixture_token: fixture-principal-installer, organization: acme, roles: []} + - {id: principal-team-member, fixture_token: fixture-principal-team-member, organization: acme, roles: [], teams: [principal-team]} + - {id: principal-team-leaver, fixture_token: fixture-principal-team-leaver, organization: acme, roles: [], teams: [principal-team]} + - {id: principal-org-leaver, fixture_token: fixture-principal-org-leaver, organization: acme, roles: []} `)); err != nil { t.Fatal(err) } + // The local authority has no membership change yet: these hooks are the + // target's, labelled as not implemented rather than faked. + notYet := func(what string) error { return errors.New("target: the local authority cannot " + what + " yet") } server := grpc.NewServer() for _, p := range []framework.Plugin{ &authority.Plugin{Issuer: k.Issuer}, @@ -84,17 +81,21 @@ people: t.Fatal(err) } conformance.RunSolutionPrincipalTarget(t, conn, descriptors, conformance.SolutionPrincipalCase{ - Organization: "acme", Generation: 1, + Organization: "acme", OtherOrganization: "globex", Generation: 1, A: conformance.SolutionUnderTest{Solution: "principal-a", Credential: k.Credential(t, "principal-a")}, B: conformance.SolutionUnderTest{Solution: "principal-b", Credential: k.Credential(t, "principal-b")}, Store: "principal-store", StoreCredential: k.Credential(t, "principal-store"), Kind: "principal.collection", - Admin: "fixture-alex", Viewer: "fixture-maya", Editor: "fixture-remi", Member: "fixture-dev", - Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", Existing: "store-x", - Team: "principal-team", TeamMember: "fixture-principal-team-member", - Uninstall: func(_ context.Context, id string) error { return k.State.Revoke(id) }, - SignManifest: testkit.SignDocument, - SignAuthority: testkit.SignAuthority, - DeliveryCredential: func(module string) string { return k.Credential(t, module) }, - HostCoordinate: "local", HostDomain: "local", + LegacyStore: "principal-legacy-store", LegacyStoreCredential: k.Credential(t, "principal-legacy-store"), LegacyKind: "principal.legacy", + Admin: "fixture-alex", Viewer: "fixture-maya", Editor: "fixture-remi", Member: "fixture-dev", Installer: "fixture-principal-installer", + Team: "principal-team", TeamMember: "fixture-principal-team-member", TeamLeaver: "fixture-principal-team-leaver", OrgLeaver: "fixture-principal-org-leaver", + Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", OtherAdmin: "fixture-zoe", OtherMember: "fixture-omar", + Existing: "store-x", + Uninstall: func(_ context.Context, id string) error { return k.State.Revoke(id) }, + RemoveFromTeam: func(context.Context, string, string) error { return notYet("remove a person from a team") }, + RemoveFromOrganization: func(context.Context, string) error { return notYet("remove a person from the organisation") }, + SignManifest: testkit.SignDocument, + SignAuthority: testkit.SignAuthority, + DeliveryCredential: func(module string) string { return k.Credential(t, module) }, + HostCoordinate: "local", HostDomain: "local", }) } From 5a4513e3ce0f0984234286724ce2f51fd32382fb Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:35:18 -0400 Subject: [PATCH 4/5] test: the solution-principal target, revised for spec review r85 Coordinator decisions: - the agent is confined: eight widening asks refused "widens"; - grant authority is per organization and per installation: another organization's administrator and A's installer acting on B are refused on GrantRole, RevokeRole and ListRoleGrants; - transfer admin is bound to the resource (an unadministered id is refused); - every audience receiving owner-selected scopes declares owner_aware, solutions included (A and B declare it; C does not and is refused); - the target supersedes Run's conflicting cases, which now assert the target model whenever the published contract is the target's, so one host passes both (solution_roles [0]-[4], installation_chain, solution_tools, the permission matrix, the callee staleness probe, installation lifecycle exposure, applyManifest; the installing person holds no role). Majors: transfer's previous owner allowlisted (the organization or its person); write on the kind, not a role name ("editor" declares read only); Check admits an owner-selected scope by kind and action and the module enforces the owner (a route case); Python and TypeScript owner-aware targets. Minors: grant idempotency, AllowsOwned on plain scopes and whole-kind owner asks, a hop positive, revival after reinstall for the agent and a parent, transfer reads roles live, own-kind confinement locally, one spelling ("organization"), owner_aware withdrawal refuses earlier capabilities, grant edge cases (disabled installation, another organization's team, the installer listing, direct plus team grant). Co-Authored-By: Claude Opus 5.5 --- .../go/workcontext/owner_target_test.go | 11 +- .../python/tests/test_owner_target.py | 38 ++ .../typescript/tests/owner-target.test.mjs | 42 ++ libraries/conformance/go/README.md | 122 ++-- libraries/conformance/go/conformance.go | 31 +- .../conformance/go/installation_chain.go | 11 +- .../conformance/go/installation_lifecycle.go | 20 +- .../conformance/go/solution_principal.go | 569 ++++++++++++------ libraries/conformance/go/solution_roles.go | 25 +- libraries/conformance/go/solution_tools.go | 7 +- libraries/hop/go/owner_target_test.go | 7 + .../code/pkg/business/testkit/testkit.go | 2 +- .../solution_principal_target_test.go | 9 +- 13 files changed, 643 insertions(+), 251 deletions(-) create mode 100644 libraries/authority-client/python/tests/test_owner_target.py create mode 100644 libraries/authority-client/typescript/tests/owner-target.test.mjs diff --git a/libraries/authority-client/go/workcontext/owner_target_test.go b/libraries/authority-client/go/workcontext/owner_target_test.go index 9912c5f8..97586d42 100644 --- a/libraries/authority-client/go/workcontext/owner_target_test.go +++ b/libraries/authority-client/go/workcontext/owner_target_test.go @@ -35,13 +35,18 @@ type ownerChecker interface { // TARGET (solution principal; conformance RunSolutionPrincipalTarget): run // with AUTHORITY_TARGET=solution-principal. Claims take the resource's owner // and refuse a mismatch; the owner-blind Allows never admits an owner-selected -// scope; an owner with ids admits only when both match. +// scope; an owner with ids admits only when both match; a whole-kind ask with +// an owner (the list path) is admitted by an owner-selected scope; a plain +// scope (an organization grant) names resources, not owners, so it admits +// whatever the owner. The method gate is coarse (kind and action): this check +// is the module's, comparing the scope's owner with the resource's. func TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner(t *testing.T) { if os.Getenv("AUTHORITY_TARGET") != "solution-principal" { t.Skip("target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal") } c := body(t, "session") - c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", "principal-u"), ownedScope(t, "note", "read", "principal-u", "n1")} + c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", "principal-u"), ownedScope(t, "note", "read", "principal-u", "n1"), + {ResourceKind: "plain", Actions: []string{"read"}}} claims := read(t, c) if claims.Allows("record", "read", "") || claims.Allows("record", "read", "r1") || claims.Allows("note", "read", "n1") { t.Fatal("target: the owner-blind Allows admits an owner-selected scope") @@ -56,6 +61,8 @@ func TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner(t *testing.T) { }{ {"record", "r1", "principal-u", true}, {"record", "r1", "principal-v", false}, {"record", "r1", "", false}, {"note", "n1", "principal-u", true}, {"note", "n2", "principal-u", false}, {"note", "n1", "principal-v", false}, + {"record", "", "principal-u", true}, {"record", "", "principal-v", false}, {"note", "", "principal-u", false}, + {"plain", "p1", "principal-u", true}, {"plain", "p1", "", true}, } { if owned.AllowsOwned(q.kind, "read", q.id, q.owner) != q.allowed { t.Fatalf("target: AllowsOwned(%s, read, %s, %s) want %v", q.kind, q.id, q.owner, q.allowed) diff --git a/libraries/authority-client/python/tests/test_owner_target.py b/libraries/authority-client/python/tests/test_owner_target.py new file mode 100644 index 00000000..573b6d64 --- /dev/null +++ b/libraries/authority-client/python/tests/test_owner_target.py @@ -0,0 +1,38 @@ +"""TARGET (solution principal; conformance RunSolutionPrincipalTarget). + +Run with AUTHORITY_TARGET=solution-principal. A module served in Python never +reads an owner-selected scope as the whole kind: the owner-blind ``allows`` +refuses it, and ``allows_owned`` admits a resource only for its owner (an +owner with ids only when both match; a plain scope names resources, not +owners). +""" +import os + +import pytest + +from authority_client.work_context import Claims, Scope + +pytestmark = pytest.mark.skipif(os.environ.get("AUTHORITY_TARGET") != "solution-principal", + reason="target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal") + + +def owned(kind, owner, *ids): + try: + return Scope(kind, ("read",), tuple(ids), resource_owner_id=owner) + except TypeError as e: + pytest.fail(f"target: Scope has no resource_owner_id: {e}") + + +def test_claims_admit_an_owner_selected_scope_only_for_its_owner(): + claims = Claims("store", (owned("record", "principal-u"), owned("note", "principal-u", "n1"), Scope("plain", ("read",), ()))) + assert not claims.allows("record", "read") and not claims.allows("record", "read", "r1") and not claims.allows("note", "read", "n1"), \ + "target: the owner-blind allows admits an owner-selected scope" + allows_owned = getattr(claims, "allows_owned", None) + assert allows_owned is not None, "target: Claims has no allows_owned(kind, action, resource_id, owner_id)" + for kind, resource, owner, allowed in [ + ("record", "r1", "principal-u", True), ("record", "r1", "principal-v", False), ("record", "r1", "", False), + ("note", "n1", "principal-u", True), ("note", "n2", "principal-u", False), ("note", "n1", "principal-v", False), + ("record", "", "principal-u", True), ("note", "", "principal-u", False), + ("plain", "p1", "principal-u", True), + ]: + assert allows_owned(kind, "read", resource, owner) == allowed, (kind, resource, owner) diff --git a/libraries/authority-client/typescript/tests/owner-target.test.mjs b/libraries/authority-client/typescript/tests/owner-target.test.mjs new file mode 100644 index 00000000..f9ead743 --- /dev/null +++ b/libraries/authority-client/typescript/tests/owner-target.test.mjs @@ -0,0 +1,42 @@ +// TARGET (solution principal; conformance RunSolutionPrincipalTarget). Run with +// AUTHORITY_TARGET=solution-principal. A module served in TypeScript never reads +// an owner-selected scope as the whole kind: the owner-blind allows refuses it, +// and allowsOwned admits a resource only for its owner (an owner with ids only +// when both match; a plain scope names resources, not owners). +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash, createPrivateKey, sign } from 'node:crypto'; +import { test } from 'node:test'; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { KeysResponseSchema, PublicKeySchema, WorkContextV1Schema, WorkScopeV1Schema } from '@codefly-dev/authority-client'; +import { authenticateClaims, trustFromKeys } from '@codefly-dev/authority-client/work-context'; + +const skip = process.env.AUTHORITY_TARGET !== 'solution-principal' && 'target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal'; +const fixtures = JSON.parse(readFileSync(new URL('../../testdata/claims.json',import.meta.url),'utf8')); +const encode = bytes => Buffer.from(bytes).toString('base64url'); +const signer = createPrivateKey({key: Buffer.concat([Buffer.from('302e020100300506032b657004220420','hex'), Buffer.alloc(32)]), format: 'der', type: 'pkcs8'}); +const publicKey = Buffer.from(signer.export({format:'jwk'}).x,'base64url'); +const body = name => fromBinary(WorkContextV1Schema, Buffer.from(fixtures.find(f=>f.name===name).token.split('.')[0],'base64url')); +const signed = context => { const payload = toBinary(WorkContextV1Schema,context); return encode(payload)+'.'+encode(sign(null,payload,signer)); }; +const trustFor = context => trustFromKeys(create(KeysResponseSchema,{issuer:context.issuer,keys:[create(PublicKeySchema,{keyId:context.keyId,algorithm:'Ed25519',publicKey})]}), ()=>(Number(context.notBeforeUnix)+1)*1000); +const stamped = token => ({'x-codefly-work-context':token,'x-codefly-hop-proof':'test-proof','x-codefly-hop-context':createHash('sha256').update(token).digest('hex')}); +const read = context => authenticateClaims(stamped(signed(context)),'test-proof',context.audience,trustFor(context)); + +test('claims admit an owner-selected scope only for its owner', {skip}, () => { + assert.ok(WorkScopeV1Schema.fields.some(f => f.name === 'resource_owner_id'), 'target: WorkScopeV1 has no resource_owner_id'); + const context = body('session'); + context.authorityScopes = [ + create(WorkScopeV1Schema,{resourceKind:'record',actions:['read'],resourceOwnerId:'principal-u'}), + create(WorkScopeV1Schema,{resourceKind:'note',actions:['read'],resourceIds:['n1'],resourceOwnerId:'principal-u'}), + create(WorkScopeV1Schema,{resourceKind:'plain',actions:['read']}), + ]; + const claims = read(context); + assert.ok(!claims.allows('record','read') && !claims.allows('record','read','r1') && !claims.allows('note','read','n1'), 'target: the owner-blind allows admits an owner-selected scope'); + assert.equal(typeof claims.allowsOwned, 'function', 'target: claims have no allowsOwned(kind, action, resourceId, ownerId)'); + for (const [kind, resource, owner, allowed] of [ + ['record','r1','principal-u',true], ['record','r1','principal-v',false], ['record','r1','',false], + ['note','n1','principal-u',true], ['note','n2','principal-u',false], ['note','n1','principal-v',false], + ['record','','principal-u',true], ['note','','principal-u',false], + ['plain','p1','principal-u',true], + ]) assert.equal(claims.allowsOwned(kind,'read',resource,owner), allowed, `${kind} ${resource} ${owner}`); +}); diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 6f674786..572132dc 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -72,52 +72,84 @@ single-organization inference remains a local convenience, not a shared promise. `RunSolutionPrincipalTarget` specifies solution identity before any host implements it. The model, in full, is the doc comment on -`SolutionPrincipalCase`: an installed solution is a principal U of its -organisation (a stable UUID, reused by a reinstall, disabled by an uninstall, -distinct per organisation); the installation acting for itself is U, with the -install request's role; the solution's roles are levels on U's data, granted -and revoked by an organisation administrator or the solution's installer -(`GrantRole`, `RevokeRole`, `ListRoleGrants`); a capability through the -solution is confined to U's data, by owner-selected scopes -(`WorkScopeV1.resource_owner_id`, field 4), issued only to an audience that -declares owner-aware checking; an existing person- or organisation-owned -resource joins U only by `AuthorizeTransfer`, with the person's own admin on it -and at least editor on U. The target deletes `GrantToTeam`, `team_ids` and -`agent_principal_id`. - -The suite first names what the published contract lacks and what it still -carries, then runs these cases, each making its own grants: - -1. install creates the solution principal and its roles; a reinstall keeps its UUID; another organisation's is another principal -2. the install request's role is the agent's, and no person's: the owner and the organisation administrator hold no role (the agent's owner id is U; it needs its operation binding) +`SolutionPrincipalCase`. In short: + +- an installed solution is a principal U of its organization: a stable UUID, + reused by a reinstall, disabled by an uninstall, distinct per organization; +- the installation acting for itself is U, with the install request's role; +- the solution's roles are levels on U's data, and role names carry no rank; + they are granted, revoked (both idempotent) and listed by an administrator + of the installation's organization or a holder of that installation's + installer role (`GrantRole`, `RevokeRole`, `ListRoleGrants`); +- a capability through the solution, a person's or U's own, is confined to U's + data by owner-selected scopes (`WorkScopeV1.resource_owner_id`, field 4), + issued only to audiences declaring owner-aware checking, solutions included; +- Check with a method admits an owner-selected scope by kind and action, and + the module enforces owner == the resource's owner (`AllowsOwned`, + `allows_owned`, `allowsOwned`); +- an existing resource owned by the organization or a person of it joins U + only by `AuthorizeTransfer`, with the person's own admin on that resource + and write on U's data through their current roles, read live. + +The target deletes `GrantToTeam`, `team_ids` and `agent_principal_id`, and +changes the composition contract's text that the installing person holds the +installer role. + +The suite first names what the published contract lacks and still carries, +then runs these cases, each making its own grants: + +1. install creates the solution principal and its roles; a reinstall keeps its UUID; another organization's is another principal +2. the install request's role is the agent's, and no person's; the agent is confined to the principal's data (eight widening asks refused) 3. without a role on the solution a person is refused, naming the reason -4. a viewer reads exactly what the principal owns (root, child, Check, `ListResources` with owner, owner with ids), and nothing a person, an installation, the organisation or another principal owns -5. a capability through the solution is confined to the principal's data; the person's own grants never flow in -6. owner-selected scopes are issued only to an audience that checks owners -7. a resource created through the solution is owned by its principal, the person its author -8. an editor writes and a viewer cannot, each by name -9. an organisation administrator or the solution's installer grants; anyone else, and every malformed grant, is refused by name -10. another installation's and another organisation's members never reach the principal's data -11. a child never widens the owner, the actions or the ids it narrowed to -12. an existing resource joins the principal only by a transfer from a person or the organisation, administered through the person's own grants, by an editor of the principal (and nine named refusals) -13. revoking a person's role stops their access at once, theirs alone; their old capabilities stay dead after a re-grant -14. a team's role is its members' access; revoking it or leaving the team stops them at once; old capabilities stay dead after a re-grant -15. a person leaving the organisation loses the solution's data at once -16. uninstalling disables the principal at once and its data stays owned; old capabilities stay dead after reinstall and re-grant - -Two library targets pin the modules' side: `libraries/hop/go` -`TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind` (the stamp never flattens an -owner-selected scope into a whole-kind one, and owner-blind `Allows` refuses -it) and `libraries/authority-client/go/workcontext` -`TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner` (`AllowsOwned` takes the -resource's owner; owner and ids both match). - -When the target joins `Run`, it supersedes Run's `GrantToTeam`/`team_ids` -steps (the installation lifecycle's exposure, the callee-staleness probe's -grant), and Run reads the installation's acting principal through -`installationPrincipal`, so one host passes both. +4. a viewer reads exactly what the principal owns, and nothing a person, an installation or another principal owns +5. a route requiring the kind admits an owner-selected scope; the module enforces the owner +6. a capability through the solution is confined to the principal's data; the person's own grants never flow in (the store's kind and the solution's own kind) +7. every audience receiving owner-selected scopes declares owner-aware checking, solutions included +8. withdrawing owner-aware checking refuses the owner-selected capabilities issued before +9. a resource created through the solution is owned by its principal, the person its author +10. a role writes when its scopes hold write, whatever it is called; a viewer cannot +11. an administrator of the organization or the installation's installer grants, revokes and lists; anyone else (another organization's administrator, another installation's installer), and every malformed grant, is refused by name; grants are idempotent +12. another installation's and another organization's members never reach the principal's data +13. a child never widens the owner, the actions or the ids it narrowed to +14. an existing resource joins the principal only by a transfer from the organization or its person, administered on that resource through the person's own grants, by a holder of write on the principal's data (fourteen named refusals) +15. revoking a person's role stops their access at once, theirs alone; their old capabilities stay dead after a re-grant +16. a team's role is its members' access; revoking it or leaving the team stops them at once; a direct grant survives the team's revoke +17. a person leaving the organization loses the solution's data at once +18. uninstalling disables the principal at once and its data stays owned; old capabilities (a person's, the agent's, a parent's children) stay dead after reinstall and re-grant + +Library targets pin the modules' side: `libraries/hop/go` +`TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind`, +`libraries/authority-client/go/workcontext` +`TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner`, +`libraries/authority-client/python/tests/test_owner_target.py` and +`libraries/authority-client/typescript/tests/owner-target.test.mjs`. + +### Run cases the target supersedes, and how one host passes both + +Each asserts the earlier model while the published contract is the earlier +one, and the target's model once it carries `Installation.principal_id` +(`client.solutionPrincipal`): + +| Run case | Under the target | +| --- | --- | +| `conformance.go` an installation acts for itself | its owner is `principal_id` (`installationPrincipal`) | +| `conformance.go` the permission matrix | a row at a solution audience asks within the owner the person holds there | +| `conformance.go` a callee refuses what does not verify (staleness) | the viewer's own role is revoked and restored (`RevokeRole`), not a team exposure | +| `conformance.go` `applyManifest` | a solution delivery declares `owner_aware` | +| `solution_roles.go` [0] own audience, [1] consumed child, [2] binding root, [4] binding ceiling | owner-selected asks and scopes; the delivery declares `owner_aware` | +| `solution_roles.go` [3] no solution role | refused "holds no role" | +| `installation_chain.go` | the agent root's owner is U; every hop is owner-selected | +| `solution_tools.go` | the asks at the solution audience are owner-selected | +| `installation_lifecycle.go` exposure | a team's `GrantRole`, read back through `ListRoleGrants` | + +A host fixture under the target grants roles with `GrantRole` (the +installing person holds no role unless granted, so a Matrix row such as the +installer publishing at a solution needs an explicit installer grant), and +declares `owner_aware` on the modules solutions consume. Run everything with `AUTHORITY_TARGET=solution-principal`: from `modules/authority/services/local/code`, -`go test ./plugins/registry_test -run SolutionPrincipal`, and `go test ./...` -in `libraries/hop/go` and `libraries/authority-client/go`. Each skips without it. +`go test ./plugins/registry_test -run SolutionPrincipal`; `go test ./...` in +`libraries/hop/go` and `libraries/authority-client/go`; `pytest` in +`libraries/authority-client/python`; `npm test` in +`libraries/authority-client/typescript`. Each target skips without it. diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index fa8eee5c..45240cb3 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -194,7 +194,13 @@ func (c *client) applyManifest(ctx context.Context, f Fixture, alias, kind strin if roles == nil { return c.deliver(ctx, f, alias, kind, generation, kinds, nil, 0) } - return c.deliver(ctx, f, alias, kind, generation, kinds, map[string]any{"scope_ceilings": ceilings, "roles": roles}, 0) + document := map[string]any{"scope_ceilings": ceilings, "roles": roles} + if c.solutionPrincipal() { + // A solution receives owner-selected scopes, so it declares owner-aware + // checking under the solution-principal contract. + document["owner_aware"] = true + } + return c.deliver(ctx, f, alias, kind, generation, kinds, document, 0) } // deliverWithMethods delivers a signed module manifest with the authority's @@ -357,8 +363,11 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt sess = c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": e.Token}) sessions[e.Token] = sess } + // Under the solution-principal contract a solution audience's scopes + // are owned by its principal: the row asks within that owner. + ask := ownedBy(map[string]any{"resource_kind": e.Kind, "actions": []any{e.Action}}, c.heldOwner(ctx, sess, e.Audience, e.Kind)) _, err := c.call(as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": e.Audience, - "scopes": []any{map[string]any{"resource_kind": e.Kind, "actions": []any{e.Action}}}}) + "scopes": []any{ask}}) switch { case e.Allowed && err != nil: t.Errorf("%s at %s: %s %s must be allowed: %v", e.Token, e.Audience, e.Kind, e.Action, err) @@ -403,6 +412,20 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt } admin := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.AdminToken}) c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": token, "audience": f.Audience}) + if c.solutionPrincipal() { + // Under the solution-principal contract a grant moves only its + // subject's authority: the callee refuses the capability whose own + // role was revoked, then the fixture's grant is restored. + viewer := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.ViewerToken}) + role := map[string]any{"organization_id": f.Organization, "installation_id": f.InstalledSolution, "role": "viewer", "person_id": str(viewer, "person_id")} + c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "GrantRole", role) + held := str(c.must(t, as(ctx, viewer), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": f.InstalledAudience}), "token") + c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "RevokeRole", role) + _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": held, "audience": f.InstalledAudience}) + refused(t, err, codes.PermissionDenied, "revoked") + c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "GrantRole", role) + return + } // A team the installation already reaches moves nothing, as a second run // against a long-lived authority finds: that is the fixture's failure. listed := c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "List", map[string]any{"organization_id": f.Organization}) @@ -521,7 +544,7 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt if f.SolutionTools == nil { t.Fatal("required conformance fixture missing: fixture supplies no module-to-solution tool case") } - c.solutionTools(t, ctx, *f.SolutionTools) + c.solutionTools(t, ctx, f, *f.SolutionTools) }) t.Run("a child hop attenuates and names its actor and its binding", func(t *testing.T) { // The hop rule: a module hops only on a token addressed to it, and the hop @@ -611,7 +634,7 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt if f.InstallationChain == nil { t.Fatal("required conformance fixture missing: the fixture states no installation-owned chain") } - c.installationChain(t, ctx, *f.InstallationChain) + c.installationChain(t, ctx, f, *f.InstallationChain) }) t.Run("a check naming a method applies what the module's delivery declares", func(t *testing.T) { if f.SignManifest == nil || f.SignAuthority == nil { diff --git a/libraries/conformance/go/installation_chain.go b/libraries/conformance/go/installation_chain.go index d7b4c41d..85627150 100644 --- a/libraries/conformance/go/installation_chain.go +++ b/libraries/conformance/go/installation_chain.go @@ -16,16 +16,19 @@ type InstallationChainCase struct { Hops []Hop } -func (c *client) installationChain(t *testing.T, ctx context.Context, f InstallationChainCase) { +func (c *client) installationChain(t *testing.T, ctx context.Context, fixture Fixture, f InstallationChainCase) { if f.Installation == "" || f.Source == "" || f.Credential == "" || len(f.Hops) < 2 { t.Fatal("installation chain requires an installed source and at least two hops") } + // Under the solution-principal contract the installation acting for itself + // is its principal, and every hop is confined to that principal's data. + owner := c.installationOwnerByID(t, ctx, fixture, f.Installation) parent := c.must(t, ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{ "installation": map[string]any{"installation_id": f.Installation}, "actor": map[string]any{"credential": f.Credential}, "audience": f.Source, }) root := msg(parent, "context") - if str(root, "owner_principal_kind") != "agent" || len(listMsgs(root, "actor_chain")) != 0 { + if str(root, "owner_principal_kind") != "agent" || len(listMsgs(root, "actor_chain")) != 0 || (owner != "" && str(root, "owner_principal_id") != owner) { t.Fatalf("installation root is not owned by its agent: %v", root) } for n, hop := range f.Hops { @@ -33,7 +36,7 @@ func (c *client) installationChain(t *testing.T, ctx context.Context, f Installa out := c.must(t, ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{ "parent_token": str(parent, "token"), "actor": map[string]any{"credential": hop.Credential}, "audience": hop.Audience, "operation_binding_id": hop.Binding, - "scopes": []any{scopeAsk(hop.AskKind, []string{hop.AskAction}, nil)}, + "scopes": []any{ownedBy(scopeAsk(hop.AskKind, []string{hop.AskAction}, nil), owner)}, }) wc := msg(out, "context") actors := listMsgs(wc, "actor_chain") @@ -53,7 +56,7 @@ func (c *client) installationChain(t *testing.T, ctx context.Context, f Installa t.Fatalf("hop %d actor %d: %v", n+1, a, actor) } } - exactEffectiveScope(t, wc, hop.AskKind, []string{hop.AskAction}, nil) + exactScopeOwnedBy(t, wc, hop.AskKind, []string{hop.AskAction}, nil, owner) c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": hop.Audience}) parent = out } diff --git a/libraries/conformance/go/installation_lifecycle.go b/libraries/conformance/go/installation_lifecycle.go index 8935b555..4b1426fc 100644 --- a/libraries/conformance/go/installation_lifecycle.go +++ b/libraries/conformance/go/installation_lifecycle.go @@ -89,13 +89,20 @@ func (c *client) installationLifecycle(t *testing.T, ctx context.Context, f Fixt return c.call(ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{"installation": map[string]any{"installation_id": id}, "actor": map[string]any{"credential": l.Credential}, "audience": l.Target}) } grant := map[string]any{"organization_id": f.Organization, "installation_id": id, "team_id": l.Team} + // Exposure is GrantToTeam before the solution-principal contract and a + // team's role grant (GrantRole) under it, which deletes GrantToTeam. + expose := "GrantToTeam" + if c.solutionPrincipal() { + expose = "GrantRole" + grant["role"] = "viewer" + } t.Run("pending installation cannot issue or be exposed", func(t *testing.T) { if enumName(pending, "status") != "STATUS_PENDING" { t.Fatal("installation became active before acknowledgement") } _, err := issue() refused(t, err, codes.PermissionDenied, "pending") - _, err = c.call(adminCtx, "host.installation.v1.InstallationService", "GrantToTeam", grant) + _, err = c.call(adminCtx, "host.installation.v1.InstallationService", expose, grant) refused(t, err, codes.PermissionDenied, "pending") }) resume() @@ -108,15 +115,20 @@ func (c *client) installationLifecycle(t *testing.T, ctx context.Context, f Fixt t.Fatal("acknowledged install did not finish") } t.Run("acknowledged installation is exposed to its team", func(t *testing.T) { - exposed := c.must(t, adminCtx, "host.installation.v1.InstallationService", "GrantToTeam", grant) - if enumName(exposed, "status") != "STATUS_ACTIVE" || count(exposed, "team_ids") != 1 || u64(exposed, "revision") != u64(pending, "revision")+1 { + exposed := c.must(t, adminCtx, "host.installation.v1.InstallationService", expose, grant) + if c.solutionPrincipal() { + listed := c.must(t, adminCtx, "host.installation.v1.InstallationService", "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": id}) + if grants := listMsgs(listed, "grants"); len(grants) != 1 || str(grants[0], "team_id") != l.Team || str(grants[0], "role") != "viewer" { + t.Fatalf("acknowledged installation was not granted to its team exactly once: %v", grants) + } + } else if enumName(exposed, "status") != "STATUS_ACTIVE" || count(exposed, "team_ids") != 1 || u64(exposed, "revision") != u64(pending, "revision")+1 { t.Fatal("acknowledged installation was not exposed exactly once") } _, err := c.call(adminCtx, "host.installation.v1.InstallationService", "Install", request) refused(t, err, codes.PermissionDenied, "already installed") foreignGrant := clone(grant) foreignGrant["organization_id"] = "conformance-foreign-org" - _, err = c.call(adminCtx, "host.installation.v1.InstallationService", "GrantToTeam", foreignGrant) + _, err = c.call(adminCtx, "host.installation.v1.InstallationService", expose, foreignGrant) refused(t, err, codes.PermissionDenied, "organization") }) out, err := issue() diff --git a/libraries/conformance/go/solution_principal.go b/libraries/conformance/go/solution_principal.go index 601fa3c5..34f6bbe6 100644 --- a/libraries/conformance/go/solution_principal.go +++ b/libraries/conformance/go/solution_principal.go @@ -16,53 +16,68 @@ import ( // SolutionPrincipalCase is the TARGET contract for solution identity; no host // passes it yet. // -// An installed solution is a principal of its organisation (U), with a stable -// UUID like a person's: one per (organisation, solution), reused by a +// An installed solution is a principal of its organization (U), with a stable +// UUID like a person's: one per (organization, solution), reused by a // reinstall, disabled by an uninstall. The installation acting for itself IS // U, holding the role its install request names; nobody else inherits that -// role. The solution's declared roles are permission levels on U's data: -// granting a person or a team a role (GrantRole) is the access, revoking it -// (RevokeRole) ends it at once, and ListRoleGrants answers who holds which. -// An organisation administrator, or a holder of the solution's installer role, -// grants and revokes. +// role, the installing person included. The solution's declared roles are +// permission levels on U's data, and role names carry no rank: what a role +// may do is the scopes it declares. Granting a person or a team a role +// (GrantRole) is the access, revoking it (RevokeRole) ends it at once, both +// idempotent, and ListRoleGrants answers who holds which. An administrator of the +// installation's organization, or a holder of the installer role on that +// installation, grants, revokes and lists; nobody else. // -// A capability issued through the solution is CONFINED to U's data: its -// scopes are owner-selected, {kind, actions, resource_owner_id: U}, from the -// solution's roles alone; a person's own organisation grants never flow in. A -// scope naming an owner and ids admits a resource only when both match. The -// authority issues owner-selected scopes only to an audience whose delivery -// declares owner-aware checking (AuthorityManifestV1.owner_aware). +// A capability issued through the solution, to a person or to U itself, is +// CONFINED to U's data: owner-selected scopes {kind, actions, +// resource_owner_id: U} from the solution's roles alone; a person's own +// organization grants never flow in (they reach solution code only through an +// explicitly declared binding, under rule A). A scope naming an owner and ids +// admits a resource only when both match. Every audience receiving +// owner-selected scopes, solutions included, declares owner-aware checking +// (AuthorityManifestV1.owner_aware); the authority refuses the rest, and a +// delivery withdrawing it refuses the owner-selected capabilities issued +// before. Check with a method admits an owner-selected scope by kind and +// action; the module then enforces owner == the resource's owner +// (Claims.AllowsOwned and its Python and TypeScript twins). // // An existing resource joins U only by AuthorizeTransfer: the store (the -// kind's serving module, which attests the resource exists and who owns it -// now) asks; the authority allows it from a person- or organisation-owned -// resource, never from another solution principal, when the person holds admin -// on it from their own organisation grants AND at least editor on U. The store -// then records U as owner; the original creator stays the author, and the -// previous owner keeps nothing through ownership. +// kind's serving module) asks, attesting the resource exists and who owns it +// now. The authority allows it when the current owner is the organization or +// a person of it (never another solution principal, another organization, +// its people or an unknown owner), the person holds admin on THAT resource +// from their own organization grants, and the person's current roles on U +// hold write on the kind. All are read live, not from the capability. The +// store then records U as owner; the original creator stays the author; the +// previous owner keeps nothing through ownership; organization grants, which +// name resources and not owners, are unaffected. // -// The suite delivers A, B, Store and LegacyStore, installs A and B, and makes -// every grant itself. The host supplies, at Organization unless stated: -// - Admin: an organisation administrator (whatever its own grants on Kind). +// The suite delivers A, B, C (a solution not declaring owner_aware), Store +// and LegacyStore, installs A, B and C, and makes every grant itself. The host +// supplies, at Organization unless stated: +// - Admin: an organization administrator (whatever its own grants on Kind). // - Viewer, Editor, Member, Installer, TeamMember, TeamLeaver, OrgLeaver: -// members with no role on A or B but what the suite grants; TeamMember and -// TeamLeaver belong to Team. +// members with no role on A, B or C but what the suite grants; TeamMember +// and TeamLeaver belong to Team. // - Administrator holds admin, and Reader holds read, on Existing from their -// own organisation grants at Store, and nothing else of Kind. +// own organization grants at Store, and nothing else of Kind. +// - NoteReader holds read on A's own kind (".note") from their own +// organization grants. // - OtherAdmin and OtherMember: an administrator and a member of -// OtherOrganization. +// OtherOrganization; OtherTeam: a team of OtherOrganization. // - Existing: a resource of Kind owned by Organization. // - Uninstall, RemoveFromTeam, RemoveFromOrganization: the host's own paths // (the interface has none). Install must converge for the suite's solutions. type SolutionPrincipalCase struct { Organization, OtherOrganization string - A, B SolutionUnderTest + A, B, C SolutionUnderTest Store, StoreCredential, Kind string LegacyStore, LegacyStoreCredential, LegacyKind string Generation uint64 Admin, Viewer, Editor, Member, Installer string Team, TeamMember, TeamLeaver, OrgLeaver string - Administrator, Reader, OtherAdmin, OtherMember string + Administrator, Reader, NoteReader string + OtherAdmin, OtherMember, OtherTeam string Existing string Uninstall func(ctx context.Context, installationID string) error RemoveFromTeam func(ctx context.Context, personID, team string) error @@ -125,10 +140,10 @@ var solutionPrincipalRemoved = []struct{ message, element string }{ var principalUUID = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) // RunSolutionPrincipalTarget drives the target over one connection. It is not -// part of Run until a host implements it; when it joins, it supersedes Run's -// GrantToTeam and team_ids cases (installation_lifecycle.go's exposure steps, -// the callee-staleness probe's GrantToTeam) and reads the installation's -// acting principal as principal_id (installationPrincipal). +// part of Run until a host implements it. It supersedes Run's cases that +// encode the earlier model; those now assert this model whenever the +// published contract is the target's (client.solutionPrincipal), so one host +// passes both. See the README for the list. func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f SolutionPrincipalCase) { t.Helper() c, err := newClient(conn, descriptors) @@ -139,54 +154,37 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des t.Fatalf("target: the published contract %s", strings.Join(problems, "; ")) } if f.Uninstall == nil || f.RemoveFromTeam == nil || f.RemoveFromOrganization == nil || f.SignManifest == nil || f.SignAuthority == nil || - f.DeliveryCredential == nil || f.Existing == "" || f.OtherOrganization == "" || f.LegacyStore == "" || f.Team == "" || - slices.Contains([]string{f.Admin, f.Viewer, f.Editor, f.Member, f.Installer, f.TeamMember, f.TeamLeaver, f.OrgLeaver, f.Administrator, f.Reader, f.OtherAdmin, f.OtherMember}, "") { + f.DeliveryCredential == nil || f.Existing == "" || f.OtherOrganization == "" || f.LegacyStore == "" || f.Team == "" || f.OtherTeam == "" || f.C.Solution == "" || + slices.Contains([]string{f.Admin, f.Viewer, f.Editor, f.Member, f.Installer, f.TeamMember, f.TeamLeaver, f.OrgLeaver, f.Administrator, f.Reader, f.NoteReader, f.OtherAdmin, f.OtherMember}, "") { t.Fatal("required conformance fixture missing: solution principal case") } ctx := context.Background() - const binding, legacy, self = "store", "legacy", "self" - delivery := Fixture{SignManifest: f.SignManifest, SignAuthority: f.SignAuthority, DeliveryCredential: f.DeliveryCredential, HostCoordinate: f.HostCoordinate, HostDomain: f.HostDomain} - every := []string{"admin", "create", "read", "write"} - if _, err := c.deliver(ctx, delivery, f.Store, "module", f.Generation, []string{f.Kind}, - map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, every, nil)}, "owner_aware": true}, 0, - solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read", f.Kind + ":write"}}}); err != nil { - t.Fatalf("deliver %s: %v", f.Store, err) - } - // The legacy store does not declare owner-aware checking. - if _, err := c.deliver(ctx, delivery, f.LegacyStore, "module", f.Generation, []string{f.LegacyKind}, - map[string]any{"scope_ceilings": []any{scopeAsk(f.LegacyKind, []string{"read"}, nil)}}, 0); err != nil { - t.Fatalf("deliver %s: %v", f.LegacyStore, err) - } - // Each role is a level on the principal's data; no role names an owner. - for _, s := range []SolutionUnderTest{f.A, f.B} { - own := s.Solution + ".note" - doc := map[string]any{ - "scope_ceilings": []any{scopeAsk(own, []string{"read", "write"}, nil)}, - "roles": []any{ - map[string]any{"name": "installer", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, every, nil)}}, - map[string]any{"name": "editor", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, []string{"create", "read", "write"}, nil)}}, - map[string]any{"name": "viewer", "scopes": []any{scopeAsk(own, []string{"read"}, nil), scopeAsk(f.Kind, []string{"read"}, nil), scopeAsk(f.LegacyKind, []string{"read"}, nil)}}, - }, - } - if _, err := c.deliver(ctx, delivery, s.Solution, "solution", f.Generation, []string{own}, doc, 0, - solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":admin", f.Kind + ":create", f.Kind + ":read", f.Kind + ":write"}}}, - solutionhost.AuthorityBinding{ID: legacy, Audience: f.LegacyStore, Operations: map[string][]string{"invoke": {f.LegacyKind + ":read"}}}); err != nil { - t.Fatalf("deliver %s: %v", s.Solution, err) - } - } - - sp := &principalSuite{c: c, f: f, ctx: ctx, sessions: map[string]protoreflect.Message{}, installed: map[string]protoreflect.Message{}} - A, B := f.A, f.B + sp := &principalSuite{c: c, f: f, ctx: ctx, sessions: map[string]protoreflect.Message{}, installed: map[string]protoreflect.Message{}, storeGeneration: f.Generation} + sp.deliverAll(t) + A, B, C := f.A, f.B, f.C U := func(t *testing.T) string { return sp.principal(t, f.Organization, A) } V := func(t *testing.T) string { return sp.principal(t, f.Organization, B) } + // Asks no capability through A may carry, by a person or by U itself. + widening := func(t *testing.T) map[string]any { + return map[string]any{ + "another installation's principal": sp.owned(f.Kind, []string{"read"}, V(t)), + "another principal's ids": sp.owned(f.Kind, []string{"read"}, V(t), "u-doc"), + "another organization's principal": sp.owned(f.Kind, []string{"read"}, sp.principal(t, f.OtherOrganization, A)), + "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), + "a resource by id alone": scopeAsk(f.Kind, []string{"read"}, []string{f.Existing}), + "a person as owner": sp.owned(f.Kind, []string{"read"}, sp.personID(t, f.Viewer)), + "the installation id as owner": sp.owned(f.Kind, []string{"read"}, str(sp.ensure(t, f.Organization, A), "id")), + "the organization as owner": sp.owned(f.Kind, []string{"read"}, f.Organization), + } + } - t.Run("install creates the solution principal and its roles; a reinstall keeps its UUID; another organisation's is another principal", func(t *testing.T) { + t.Run("install creates the solution principal and its roles; a reinstall keeps its UUID; another organization's is another principal", func(t *testing.T) { a, b := sp.ensure(t, f.Organization, A), sp.ensure(t, f.Organization, B) for _, inst := range []protoreflect.Message{a, b} { if id := str(inst, "principal_id"); !principalUUID.MatchString(id) || id == str(inst, "id") { t.Fatalf("principal_id %q is not a principal UUID of its own", id) } - if got := slices.Sorted(slices.Values(listStrings(inst, "roles"))); !slices.Equal(got, []string{"editor", "installer", "viewer"}) { + if got := slices.Sorted(slices.Values(listStrings(inst, "roles"))); !slices.Equal(got, []string{"author", "editor", "installer", "viewer"}) { t.Fatalf("the installation's roles are %v, want the solution's declared roles", got) } } @@ -194,7 +192,7 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des t.Fatal("two installations share a principal") } if other := sp.principal(t, f.OtherOrganization, A); other == U(t) { - t.Fatal("one solution installed in two organisations shares a principal") + t.Fatal("one solution installed in two organizations shares a principal") } before := U(t) sp.uninstall(t, f.Organization, A) @@ -202,21 +200,25 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des t.Fatalf("a reinstall made principal %s, want %s: its data would be orphaned", after, before) } }) - t.Run("the install request's role is the agent's, and no person's: the owner and the organisation administrator hold no role", func(t *testing.T) { - byAgent := sp.must(t)(sp.agent(t, A, binding)) + t.Run("the install request's role is the agent's, and no person's; the agent is confined to the principal's data", func(t *testing.T) { + byAgent := sp.must(t)(sp.agent(t, A, "store")) wc := msg(byAgent, "context") exactOwnedScope(t, wc, f.Kind, []string{"create", "read", "write"}, U(t)) if str(wc, "owner_principal_kind") != "agent" || str(wc, "owner_principal_id") != U(t) { t.Fatalf("the installation acting for itself is %s (%s), want the solution principal %s", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind"), U(t)) } - _, err := sp.agent(t, A, binding, sp.owned(f.Kind, []string{"admin"}, U(t))) + _, err := sp.agent(t, A, "store", sp.owned(f.Kind, []string{"admin"}, U(t))) refused(t, err, codes.PermissionDenied, "beyond") _, err = sp.agent(t, A, "") refused(t, err, codes.PermissionDenied, "binding") + for name, ask := range widening(t) { + _, err := sp.agent(t, A, "store", ask) + t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) + } _, err = sp.root(t, f.Admin, A) refused(t, err, codes.PermissionDenied, "holds no role") - if got := sp.roleGrants(t, A); len(got) != 0 { - t.Fatalf("an installation nobody was granted lists %v", got) + if got := sp.roleGrants(t, f.Admin, f.Organization, A); slices.ContainsFunc(got, func(g string) bool { return strings.HasSuffix(g, " person "+sp.personID(t, f.Admin)) }) { + t.Fatalf("the installing person holds a role: %v", got) } }) t.Run("without a role on the solution a person is refused, naming the reason", func(t *testing.T) { @@ -227,116 +229,149 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) root := sp.must(t)(sp.root(t, f.Viewer, A)) exactOwnedScope(t, msg(root, "context"), A.Solution+".note", []string{"read"}, U(t)) - out := sp.must(t)(sp.through(t, f.Viewer, A, binding)) + out := sp.must(t)(sp.through(t, f.Viewer, A, "store")) exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, U(t)) - c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.Store}) sp.listed(t, out, f.Kind, "read", map[string]string{"*": U(t)}) - narrowed := sp.must(t)(sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"read"}, U(t), "u-doc"))) + narrowed := sp.must(t)(sp.through(t, f.Viewer, A, "store", sp.owned(f.Kind, []string{"read"}, U(t), "u-doc"))) exactOwnedScopeIDs(t, msg(narrowed, "context"), f.Kind, []string{"read"}, U(t), []string{"u-doc"}) sp.listed(t, narrowed, f.Kind, "read", map[string]string{"u-doc": U(t)}) - for name, ask := range map[string]any{ - "another installation's principal": sp.owned(f.Kind, []string{"read"}, V(t)), - "another principal's ids": sp.owned(f.Kind, []string{"read"}, V(t), "u-doc"), - "another organisation's principal": sp.owned(f.Kind, []string{"read"}, sp.principal(t, f.OtherOrganization, A)), - "the whole kind": scopeAsk(f.Kind, []string{"read"}, nil), - "a resource by id alone": scopeAsk(f.Kind, []string{"read"}, []string{f.Existing}), - "a person as owner": sp.owned(f.Kind, []string{"read"}, sp.personID(t, f.Viewer)), - "the installation id as owner": sp.owned(f.Kind, []string{"read"}, str(sp.ensure(t, f.Organization, A), "id")), - "the organisation as owner": sp.owned(f.Kind, []string{"read"}, f.Organization), - } { - _, err := sp.through(t, f.Viewer, A, binding, ask) + for name, ask := range widening(t) { + _, err := sp.through(t, f.Viewer, A, "store", ask) t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) } }) + t.Run("a route requiring the kind admits an owner-selected scope; the module enforces the owner", func(t *testing.T) { + // The method gate is coarse (kind and action); the checked context + // names the owner, which the module compares with the resource's owner + // (the library targets pin AllowsOwned refusing another owner). + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + out := sp.must(t)(sp.through(t, f.Viewer, A, "store")) + checked := c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.Store, "method": storeReadMethod}) + exactOwnedScope(t, msg(checked, "context"), f.Kind, []string{"read"}, U(t)) + _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.Store, "method": storeWriteMethod}) + refused(t, err, codes.PermissionDenied, "requires") + }) t.Run("a capability through the solution is confined to the principal's data; the person's own grants never flow in", func(t *testing.T) { for _, token := range []string{f.Reader, f.Administrator} { sp.grant(t, A, "viewer", sp.person(t, token)) - out := sp.must(t)(sp.through(t, token, A, binding)) + out := sp.must(t)(sp.through(t, token, A, "store")) exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, U(t)) } - _, err := sp.through(t, f.Reader, A, binding, scopeAsk(f.Kind, []string{"read"}, []string{f.Existing})) + _, err := sp.through(t, f.Reader, A, "store", scopeAsk(f.Kind, []string{"read"}, []string{f.Existing})) refused(t, err, codes.PermissionDenied, "confined") - _, err = sp.through(t, f.Administrator, A, binding, scopeAsk(f.Kind, []string{"admin"}, []string{f.Existing})) + _, err = sp.through(t, f.Administrator, A, "store", scopeAsk(f.Kind, []string{"admin"}, []string{f.Existing})) refused(t, err, codes.PermissionDenied, "confined") + // At the solution's own kind too: an organization grant on it stays out. + sp.grant(t, A, "viewer", sp.person(t, f.NoteReader)) + exactOwnedScope(t, msg(sp.must(t)(sp.root(t, f.NoteReader, A)), "context"), A.Solution+".note", []string{"read"}, U(t)) }) - t.Run("owner-selected scopes are issued only to an audience that checks owners", func(t *testing.T) { + t.Run("every audience receiving owner-selected scopes declares owner-aware checking, solutions included", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - _, err := sp.through(t, f.Viewer, A, legacy) + _, err := sp.through(t, f.Viewer, A, "legacy") refused(t, err, codes.PermissionDenied, "owner-aware") - _, err = sp.agent(t, A, legacy) + _, err = sp.agent(t, A, "legacy") refused(t, err, codes.PermissionDenied, "owner-aware") + sp.grant(t, C, "viewer", sp.person(t, f.Viewer)) + _, err = sp.root(t, f.Viewer, C) + refused(t, err, codes.PermissionDenied, "owner-aware") + }) + t.Run("withdrawing owner-aware checking refuses the owner-selected capabilities issued before", func(t *testing.T) { + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + before := sp.must(t)(sp.through(t, f.Viewer, A, "store")) + sp.deliverStore(t, false) + refused(t, sp.check(before), codes.PermissionDenied, "owner-aware") + sp.deliverStore(t, true) }) t.Run("a resource created through the solution is owned by its principal, the person its author", func(t *testing.T) { - sp.grant(t, A, "editor", sp.person(t, f.Editor)) - out := sp.must(t)(sp.through(t, f.Editor, A, binding, sp.owned(f.Kind, []string{"create"}, U(t)))) + sp.grant(t, A, "author", sp.person(t, f.Editor)) + out := sp.must(t)(sp.through(t, f.Editor, A, "store", sp.owned(f.Kind, []string{"create"}, U(t)))) wc := msg(out, "context") exactOwnedScope(t, wc, f.Kind, []string{"create"}, U(t)) if str(wc, "owner_principal_id") != sp.personID(t, f.Editor) || str(wc, "owner_principal_kind") != "human" { t.Fatalf("the author is %s (%s), want the person", str(wc, "owner_principal_id"), str(wc, "owner_principal_kind")) } - byAgent := sp.must(t)(sp.agent(t, A, binding, sp.owned(f.Kind, []string{"create"}, U(t)))) + byAgent := sp.must(t)(sp.agent(t, A, "store", sp.owned(f.Kind, []string{"create"}, U(t)))) exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create"}, U(t)) }) - t.Run("an editor writes and a viewer cannot, each by name", func(t *testing.T) { - sp.grant(t, A, "editor", sp.person(t, f.Editor)) - sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - out := sp.must(t)(sp.through(t, f.Editor, A, binding, sp.owned(f.Kind, []string{"write"}, U(t)))) + t.Run("a role writes when its scopes hold write, whatever it is called; a viewer cannot", func(t *testing.T) { + sp.grant(t, A, "author", sp.person(t, f.Editor)) + sp.grant(t, A, "editor", sp.person(t, f.Member)) + out := sp.must(t)(sp.through(t, f.Editor, A, "store", sp.owned(f.Kind, []string{"write"}, U(t)))) exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"write"}, U(t)) - _, err := sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"write"}, U(t))) + // "editor" here declares read only: a name grants nothing. + _, err := sp.through(t, f.Member, A, "store", sp.owned(f.Kind, []string{"write"}, U(t))) refused(t, err, codes.PermissionDenied, "widens") + sp.revoke(t, A, "editor", sp.person(t, f.Member)) }) - t.Run("an organisation administrator or the solution's installer grants; anyone else, and every malformed grant, is refused by name", func(t *testing.T) { + t.Run("an administrator of the organization or the installation's installer grants, revokes and lists; anyone else, and every malformed grant, is refused by name", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) sp.grant(t, A, "installer", sp.person(t, f.Installer)) - // The installer role grants and revokes on its own installation. + // Grants are idempotent: twice is one row. + sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) + if n := countOf(sp.roleGrants(t, f.Admin, f.Organization, A), "viewer person "+sp.personID(t, f.Viewer)); n != 1 { + t.Fatalf("a repeated grant is listed %d times", n) + } sp.roleAs(t, f.Installer, "GrantRole", f.Organization, A, "viewer", sp.person(t, f.Member)) sp.roleAs(t, f.Installer, "RevokeRole", f.Organization, A, "viewer", sp.person(t, f.Member)) - inst := str(sp.ensure(t, f.Organization, A), "id") + sp.roleGrants(t, f.Installer, f.Organization, A) + instA, instB := str(sp.ensure(t, f.Organization, A), "id"), str(sp.ensure(t, f.Organization, B), "id") + grant := func(installation, role string, subject map[string]any) map[string]any { + out := map[string]any{"organization_id": f.Organization, "installation_id": installation, "role": role} + for k, v := range subject { + out[k] = v + } + return out + } + member := map[string]any{"person_id": sp.personID(t, f.Member)} for name, r := range map[string]struct { - caller string - fields map[string]any - code codes.Code - reason string + caller, method string + fields map[string]any + code codes.Code + reason string }{ - "a viewer granting itself installer": {f.Viewer, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "installer", "person_id": sp.personID(t, f.Viewer)}, codes.PermissionDenied, "may not grant"}, - "another organisation's id": {f.Admin, map[string]any{"organization_id": f.OtherOrganization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.Member)}, codes.PermissionDenied, "organization"}, - "an undeclared role": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "owner", "person_id": sp.personID(t, f.Member)}, codes.NotFound, "declares no role"}, - "both a person and a team": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.Member), "team_id": f.Team}, codes.InvalidArgument, "exactly one"}, - "neither a person nor a team": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "viewer"}, codes.InvalidArgument, "exactly one"}, - "a person of another organisation": {f.Admin, map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.OtherMember)}, codes.PermissionDenied, "not a member"}, + "a viewer granting itself installer": {f.Viewer, "GrantRole", grant(instA, "installer", sp.person(t, f.Viewer)), codes.PermissionDenied, "may not grant"}, + "a viewer revoking the installer": {f.Viewer, "RevokeRole", grant(instA, "installer", sp.person(t, f.Installer)), codes.PermissionDenied, "may not grant"}, + "a viewer listing grants": {f.Viewer, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instA}, codes.PermissionDenied, "may not"}, + "another organization's administrator granting": {f.OtherAdmin, "GrantRole", grant(instA, "viewer", member), codes.PermissionDenied, "may not grant"}, + "another organization's administrator revoking": {f.OtherAdmin, "RevokeRole", grant(instA, "viewer", sp.person(t, f.Viewer)), codes.PermissionDenied, "may not grant"}, + "another organization's administrator listing": {f.OtherAdmin, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instA}, codes.PermissionDenied, "may not"}, + "A's installer granting on B": {f.Installer, "GrantRole", grant(instB, "author", sp.person(t, f.Installer)), codes.PermissionDenied, "may not grant"}, + "A's installer revoking on B": {f.Installer, "RevokeRole", grant(instB, "viewer", member), codes.PermissionDenied, "may not grant"}, + "another organization's id": {f.Admin, "GrantRole", map[string]any{"organization_id": f.OtherOrganization, "installation_id": instA, "role": "viewer", "person_id": sp.personID(t, f.Member)}, codes.PermissionDenied, "organization"}, + "an undeclared role": {f.Admin, "GrantRole", grant(instA, "owner", member), codes.NotFound, "declares no role"}, + "both a person and a team": {f.Admin, "GrantRole", grant(instA, "viewer", map[string]any{"person_id": sp.personID(t, f.Member), "team_id": f.Team}), codes.InvalidArgument, "exactly one"}, + "neither a person nor a team": {f.Admin, "GrantRole", grant(instA, "viewer", nil), codes.InvalidArgument, "exactly one"}, + "a person of another organization": {f.Admin, "GrantRole", grant(instA, "viewer", sp.person(t, f.OtherMember)), codes.PermissionDenied, "not a member"}, + "a team of another organization": {f.Admin, "GrantRole", grant(instA, "viewer", map[string]any{"team_id": f.OtherTeam}), codes.PermissionDenied, "not a team of"}, } { t.Run(name, func(t *testing.T) { - _, err := c.call(sp.as(t, r.caller), "host.installation.v1.InstallationService", "GrantRole", r.fields) + _, err := c.call(sp.as(t, r.caller), "host.installation.v1.InstallationService", r.method, r.fields) refused(t, err, r.code, r.reason) }) } - _, err := c.call(sp.as(t, f.Viewer), "host.installation.v1.InstallationService", "RevokeRole", map[string]any{"organization_id": f.Organization, "installation_id": inst, "role": "installer", "person_id": sp.personID(t, f.Installer)}) - refused(t, err, codes.PermissionDenied, "may not grant") - _, err = c.call(sp.as(t, f.Viewer), "host.installation.v1.InstallationService", "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": inst}) - refused(t, err, codes.PermissionDenied, "may not") }) - t.Run("another installation's and another organisation's members never reach the principal's data", func(t *testing.T) { + t.Run("another installation's and another organization's members never reach the principal's data", func(t *testing.T) { sp.grant(t, B, "viewer", sp.person(t, f.Member)) _, err := sp.root(t, f.Member, A) refused(t, err, codes.PermissionDenied, "holds no role") - out := sp.must(t)(sp.through(t, f.Member, B, binding)) + out := sp.must(t)(sp.through(t, f.Member, B, "store")) exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, V(t)) - _, err = sp.through(t, f.Member, B, binding, sp.owned(f.Kind, []string{"read"}, U(t))) + _, err = sp.through(t, f.Member, B, "store", sp.owned(f.Kind, []string{"read"}, U(t))) refused(t, err, codes.PermissionDenied, "widens") other := sp.principal(t, f.OtherOrganization, A) - sp.roleIn(t, f.OtherAdmin, f.OtherOrganization, A, "viewer", sp.person(t, f.OtherMember)) - elsewhere := sp.must(t)(sp.through(t, f.OtherMember, A, binding)) + sp.roleAs(t, f.OtherAdmin, "GrantRole", f.OtherOrganization, A, "viewer", sp.person(t, f.OtherMember)) + elsewhere := sp.must(t)(sp.through(t, f.OtherMember, A, "store")) exactOwnedScope(t, msg(elsewhere, "context"), f.Kind, []string{"read"}, other) - _, err = sp.through(t, f.OtherMember, A, binding, sp.owned(f.Kind, []string{"read"}, U(t))) + _, err = sp.through(t, f.OtherMember, A, "store", sp.owned(f.Kind, []string{"read"}, U(t))) refused(t, err, codes.PermissionDenied, "widens") }) t.Run("a child never widens the owner, the actions or the ids it narrowed to", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - sp.grant(t, A, "editor", sp.person(t, f.Editor)) - narrowed := sp.must(t)(sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"read"}, U(t)))) + sp.grant(t, A, "author", sp.person(t, f.Editor)) + narrowed := sp.must(t)(sp.through(t, f.Viewer, A, "store", sp.owned(f.Kind, []string{"read"}, U(t)))) exactOwnedScope(t, msg(sp.must(t)(sp.hop(t, narrowed)), "context"), f.Kind, []string{"read"}, U(t)) - readOnly := sp.must(t)(sp.through(t, f.Editor, A, binding, sp.owned(f.Kind, []string{"read"}, U(t)))) - oneDoc := sp.must(t)(sp.through(t, f.Viewer, A, binding, sp.owned(f.Kind, []string{"read"}, U(t), "u-doc"))) + readOnly := sp.must(t)(sp.through(t, f.Editor, A, "store", sp.owned(f.Kind, []string{"read"}, U(t)))) + oneDoc := sp.must(t)(sp.through(t, f.Viewer, A, "store", sp.owned(f.Kind, []string{"read"}, U(t), "u-doc"))) exactOwnedScopeIDs(t, msg(sp.must(t)(sp.hop(t, oneDoc)), "context"), f.Kind, []string{"read"}, U(t), []string{"u-doc"}) for name, attempt := range map[string]struct { parent protoreflect.Message @@ -351,15 +386,17 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des t.Run(name, func(t *testing.T) { refused(t, err, codes.PermissionDenied, "widens") }) } }) - t.Run("an existing resource joins the principal only by a transfer from a person or the organisation, administered through the person's own grants, by an editor of the principal", func(t *testing.T) { - sp.grant(t, A, "viewer", sp.person(t, f.Administrator)) - asViewer := sp.must(t)(sp.through(t, f.Administrator, A, binding)) - _, err := sp.transfer(t, asViewer, f.StoreCredential, f.Existing, f.Organization, U(t)) - refused(t, err, codes.PermissionDenied, "editor") + t.Run("an existing resource joins the principal only by a transfer from the organization or its person, administered on that resource through the person's own grants, by a holder of write on the principal's data", func(t *testing.T) { + sp.revoke(t, A, "author", sp.person(t, f.Administrator)) sp.grant(t, A, "editor", sp.person(t, f.Administrator)) - administers := sp.must(t)(sp.through(t, f.Administrator, A, binding)) - for from, owner := range map[string]string{"the organisation": f.Organization, "a person": sp.personID(t, f.Administrator)} { - out, err := sp.transfer(t, administers, f.StoreCredential, f.Existing, owner, U(t)) + asEditorInName := sp.must(t)(sp.through(t, f.Administrator, A, "store")) + _, err := sp.transfer(t, asEditorInName, f.StoreCredential, f.Existing, f.Organization, U(t)) + refused(t, err, codes.PermissionDenied, "write on") + // Read live: the same capability is allowed once the person's roles + // hold write, whatever they are called. + sp.grant(t, A, "author", sp.person(t, f.Administrator)) + for from, owner := range map[string]string{"the organization": f.Organization, "a person of it": sp.personID(t, f.Administrator)} { + out, err := sp.transfer(t, asEditorInName, f.StoreCredential, f.Existing, owner, U(t)) t.Run("from "+from, func(t *testing.T) { if err != nil { t.Fatal(err) @@ -369,11 +406,12 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des } }) } - sp.grant(t, A, "editor", sp.person(t, f.Reader)) + administers := asEditorInName + sp.grant(t, A, "author", sp.person(t, f.Reader)) sp.grant(t, A, "installer", sp.person(t, f.Installer)) - reads := sp.must(t)(sp.through(t, f.Reader, A, binding)) - installs := sp.must(t)(sp.through(t, f.Installer, A, binding)) - byAgent := sp.must(t)(sp.agent(t, A, binding)) + reads := sp.must(t)(sp.through(t, f.Reader, A, "store")) + installs := sp.must(t)(sp.through(t, f.Installer, A, "store")) + byAgent := sp.must(t)(sp.agent(t, A, "store")) atRoot := sp.must(t)(sp.root(t, f.Administrator, A)) for name, r := range map[string]struct { capability protoreflect.Message @@ -382,9 +420,14 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des reason string }{ "from another solution principal": {administers, f.StoreCredential, f.Existing, V(t), U(t), codes.PermissionDenied, "solution principal"}, + "from another organization": {administers, f.StoreCredential, f.Existing, f.OtherOrganization, U(t), codes.PermissionDenied, "not a person or the organization"}, + "from another organization's person": {administers, f.StoreCredential, f.Existing, sp.personID(t, f.OtherMember), U(t), codes.PermissionDenied, "not a person or the organization"}, + "from an unknown owner": {administers, f.StoreCredential, f.Existing, "unknown-owner", U(t), codes.PermissionDenied, "not a person or the organization"}, + "from no owner": {administers, f.StoreCredential, f.Existing, "", U(t), codes.PermissionDenied, "not a person or the organization"}, + "of a resource the person does not administer": {administers, f.StoreCredential, f.Existing + "-unadministered", f.Organization, U(t), codes.PermissionDenied, "own organization grants"}, "to a principal the capability is not through": {administers, f.StoreCredential, f.Existing, f.Organization, V(t), codes.PermissionDenied, "issued through"}, - "by a reader of the resource": {reads, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "own organisation grants"}, - "by the installer role alone": {installs, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "own organisation grants"}, + "by a reader of the resource": {reads, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "own organization grants"}, + "by the installer role alone": {installs, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "own organization grants"}, "by the installation's agent": {byAgent, f.StoreCredential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "a person's act"}, "asked by a module that does not serve the kind": {administers, f.A.Credential, f.Existing, f.Organization, U(t), codes.PermissionDenied, "does not serve"}, "with a capability addressed elsewhere": {atRoot, f.StoreCredential, f.Existing, f.Organization, U(t), codes.Unauthenticated, "audience"}, @@ -397,11 +440,11 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des }) t.Run("revoking a person's role stops their access at once, theirs alone; their old capabilities stay dead after a re-grant", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - sp.grant(t, A, "editor", sp.person(t, f.Editor)) - before := sp.must(t)(sp.through(t, f.Viewer, A, binding)) - unrelated := sp.must(t)(sp.through(t, f.Editor, A, binding)) - if !slices.Contains(sp.roleGrants(t, A), "viewer person "+sp.personID(t, f.Viewer)) { - t.Fatalf("ListRoleGrants does not show the grant: %v", sp.roleGrants(t, A)) + sp.grant(t, A, "author", sp.person(t, f.Editor)) + before := sp.must(t)(sp.through(t, f.Viewer, A, "store")) + unrelated := sp.must(t)(sp.through(t, f.Editor, A, "store")) + if !slices.Contains(sp.roleGrants(t, f.Admin, f.Organization, A), "viewer person "+sp.personID(t, f.Viewer)) { + t.Fatalf("ListRoleGrants does not show the grant: %v", sp.roleGrants(t, f.Admin, f.Organization, A)) } sp.revoke(t, A, "viewer", sp.person(t, f.Viewer)) refused(t, sp.check(before), codes.PermissionDenied, "revoked") @@ -412,29 +455,35 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des if err := sp.check(unrelated); err != nil { t.Fatalf("revoking one person's role invalidated another's capability: %v", err) } - if slices.Contains(sp.roleGrants(t, A), "viewer person "+sp.personID(t, f.Viewer)) { + if slices.Contains(sp.roleGrants(t, f.Admin, f.Organization, A), "viewer person "+sp.personID(t, f.Viewer)) { t.Fatal("ListRoleGrants still shows a revoked grant") } sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - exactOwnedScope(t, msg(sp.must(t)(sp.through(t, f.Viewer, A, binding)), "context"), f.Kind, []string{"read"}, U(t)) + exactOwnedScope(t, msg(sp.must(t)(sp.through(t, f.Viewer, A, "store")), "context"), f.Kind, []string{"read"}, U(t)) refused(t, sp.check(before), codes.PermissionDenied, "revoked") }) - t.Run("a team's role is its members' access; revoking it or leaving the team stops them at once; old capabilities stay dead after a re-grant", func(t *testing.T) { + t.Run("a team's role is its members' access; revoking it or leaving the team stops them at once; a direct grant survives the team's revoke", func(t *testing.T) { team := map[string]any{"team_id": f.Team} sp.grant(t, A, "viewer", team) - if !slices.Contains(sp.roleGrants(t, A), "viewer team "+f.Team) { - t.Fatalf("ListRoleGrants does not show the team grant: %v", sp.roleGrants(t, A)) + if !slices.Contains(sp.roleGrants(t, f.Admin, f.Organization, A), "viewer team "+f.Team) { + t.Fatalf("ListRoleGrants does not show the team grant: %v", sp.roleGrants(t, f.Admin, f.Organization, A)) } - before := sp.must(t)(sp.through(t, f.TeamMember, A, binding)) + before := sp.must(t)(sp.through(t, f.TeamMember, A, "store")) exactOwnedScope(t, msg(before, "context"), f.Kind, []string{"read"}, U(t)) sp.revoke(t, A, "viewer", team) refused(t, sp.check(before), codes.PermissionDenied, "revoked") _, err := sp.root(t, f.TeamMember, A) refused(t, err, codes.PermissionDenied, "holds no role") + // A direct grant and a team grant: revoking the team leaves the direct one. + sp.grant(t, A, "viewer", sp.person(t, f.TeamMember)) + sp.grant(t, A, "viewer", team) + sp.revoke(t, A, "viewer", team) + sp.must(t)(sp.through(t, f.TeamMember, A, "store")) + sp.revoke(t, A, "viewer", sp.person(t, f.TeamMember)) sp.grant(t, A, "viewer", team) - sp.must(t)(sp.through(t, f.TeamMember, A, binding)) + sp.must(t)(sp.through(t, f.TeamMember, A, "store")) refused(t, sp.check(before), codes.PermissionDenied, "revoked") - leaving := sp.must(t)(sp.through(t, f.TeamLeaver, A, binding)) + leaving := sp.must(t)(sp.through(t, f.TeamLeaver, A, "store")) if err := f.RemoveFromTeam(ctx, sp.personID(t, f.TeamLeaver), f.Team); err != nil { t.Fatal(err) } @@ -442,9 +491,9 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des _, err = sp.root(t, f.TeamLeaver, A) refused(t, err, codes.PermissionDenied, "holds no role") }) - t.Run("a person leaving the organisation loses the solution's data at once", func(t *testing.T) { + t.Run("a person leaving the organization loses the solution's data at once", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.OrgLeaver)) - before := sp.must(t)(sp.through(t, f.OrgLeaver, A, binding)) + before := sp.must(t)(sp.through(t, f.OrgLeaver, A, "store")) if err := f.RemoveFromOrganization(ctx, sp.personID(t, f.OrgLeaver)); err != nil { t.Fatal(err) } @@ -452,34 +501,118 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des }) t.Run("uninstalling disables the principal at once and its data stays owned; old capabilities stay dead after reinstall and re-grant", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - sp.grant(t, A, "editor", sp.person(t, f.Administrator)) - before := sp.must(t)(sp.through(t, f.Viewer, A, binding)) + sp.grant(t, A, "author", sp.person(t, f.Administrator)) + before := sp.must(t)(sp.through(t, f.Viewer, A, "store")) parent := sp.must(t)(sp.root(t, f.Viewer, A)) - administers := sp.must(t)(sp.through(t, f.Administrator, A, binding)) - u := U(t) + administers := sp.must(t)(sp.through(t, f.Administrator, A, "store")) + byAgent := sp.must(t)(sp.agent(t, A, "store")) + u, inst := U(t), str(sp.ensure(t, f.Organization, A), "id") sp.uninstall(t, f.Organization, A, func() { refused(t, sp.check(before), codes.PermissionDenied, "revoked") - _, err := sp.child(t, parent, A, binding) + refused(t, sp.check(byAgent), codes.PermissionDenied, "revoked") + _, err := sp.child(t, parent, A, "store") refused(t, err, codes.PermissionDenied, "revoked") _, err = sp.transfer(t, administers, f.StoreCredential, f.Existing, f.Organization, u) refused(t, err, codes.PermissionDenied, "revoked") - _, err = sp.agent(t, A, binding) + _, err = sp.agent(t, A, "store") refused(t, err, codes.FailedPrecondition, "not installed") _, err = sp.root(t, f.Viewer, A) refused(t, err, codes.FailedPrecondition, "not installed") + _, err = c.call(sp.as(t, f.Admin), "host.installation.v1.InstallationService", "GrantRole", map[string]any{ + "organization_id": f.Organization, "installation_id": inst, "role": "viewer", "person_id": sp.personID(t, f.Viewer)}) + refused(t, err, codes.FailedPrecondition, "not installed") }) if U(t) != u { t.Fatalf("reinstalled as %s, want %s", U(t), u) } - refused(t, sp.check(before), codes.PermissionDenied, "revoked") _, err := sp.root(t, f.Viewer, A) refused(t, err, codes.PermissionDenied, "holds no role") sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) - exactOwnedScope(t, msg(sp.must(t)(sp.through(t, f.Viewer, A, binding)), "context"), f.Kind, []string{"read"}, u) + exactOwnedScope(t, msg(sp.must(t)(sp.through(t, f.Viewer, A, "store")), "context"), f.Kind, []string{"read"}, u) refused(t, sp.check(before), codes.PermissionDenied, "revoked") + refused(t, sp.check(byAgent), codes.PermissionDenied, "revoked") + _, err = sp.child(t, parent, A, "store") + refused(t, err, codes.PermissionDenied, "revoked") }) } +// The store's method policy: a route requiring read on the kind, and one +// requiring write. +const ( + storeReadMethod = "/principal.Store/Read" + storeWriteMethod = "/principal.Store/Write" +) + +// deliverAll delivers the store, the legacy store, and the three solutions: +// A and B declaring owner-aware checking, C not. +func (sp *principalSuite) deliverAll(t *testing.T) { + t.Helper() + f := sp.f + sp.deliverStore(t, true) + if _, err := sp.c.deliver(sp.ctx, sp.delivery(), f.LegacyStore, "module", f.Generation, []string{f.LegacyKind}, + map[string]any{"scope_ceilings": []any{scopeAsk(f.LegacyKind, []string{"read"}, nil)}}, 0); err != nil { + t.Fatalf("deliver %s: %v", f.LegacyStore, err) + } + every := []string{"admin", "create", "read", "write"} + for _, s := range []SolutionUnderTest{f.A, f.B, f.C} { + own := s.Solution + ".note" + doc := map[string]any{ + "scope_ceilings": []any{scopeAsk(own, []string{"read", "write"}, nil)}, + // Role names carry no rank: "editor" declares read only, "author" writes. + "roles": []any{ + map[string]any{"name": "installer", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, every, nil)}}, + map[string]any{"name": "author", "scopes": []any{scopeAsk(own, []string{"read", "write"}, nil), scopeAsk(f.Kind, []string{"create", "read", "write"}, nil)}}, + map[string]any{"name": "editor", "scopes": []any{scopeAsk(own, []string{"read"}, nil), scopeAsk(f.Kind, []string{"read"}, nil)}}, + map[string]any{"name": "viewer", "scopes": []any{scopeAsk(own, []string{"read"}, nil), scopeAsk(f.Kind, []string{"read"}, nil), scopeAsk(f.LegacyKind, []string{"read"}, nil)}}, + }, + } + if s != f.C { + doc["owner_aware"] = true + } + if _, err := sp.c.deliver(sp.ctx, sp.delivery(), s.Solution, "solution", f.Generation, []string{own}, doc, 0, + solutionhost.AuthorityBinding{ID: "store", Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":admin", f.Kind + ":create", f.Kind + ":read", f.Kind + ":write"}}}, + solutionhost.AuthorityBinding{ID: "legacy", Audience: f.LegacyStore, Operations: map[string][]string{"invoke": {f.LegacyKind + ":read"}}}); err != nil { + t.Fatalf("deliver %s: %v", s.Solution, err) + } + } +} + +// deliverStore delivers the store at its next generation, declaring +// owner-aware checking or not. +func (sp *principalSuite) deliverStore(t *testing.T, ownerAware bool) { + t.Helper() + f := sp.f + doc := map[string]any{ + "scope_ceilings": []any{scopeAsk(f.Kind, []string{"admin", "create", "read", "write"}, nil)}, + "methods": []any{ + map[string]any{"method": storeReadMethod, "requires": []any{scopeAsk(f.Kind, []string{"read"}, nil)}}, + map[string]any{"method": storeWriteMethod, "requires": []any{scopeAsk(f.Kind, []string{"write"}, nil)}}, + }, + } + if ownerAware { + doc["owner_aware"] = true + } + if _, err := sp.c.deliver(sp.ctx, sp.delivery(), f.Store, "module", sp.storeGeneration, []string{f.Kind}, doc, 0, + solutionhost.AuthorityBinding{ID: "self", Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read", f.Kind + ":write"}}}); err != nil { + t.Fatalf("deliver %s: %v", f.Store, err) + } + sp.storeGeneration++ +} + +func (sp *principalSuite) delivery() Fixture { + return Fixture{SignManifest: sp.f.SignManifest, SignAuthority: sp.f.SignAuthority, DeliveryCredential: sp.f.DeliveryCredential, HostCoordinate: sp.f.HostCoordinate, HostDomain: sp.f.HostDomain} +} + +func countOf(list []string, want string) int { + n := 0 + for _, v := range list { + if v == want { + n++ + } + } + return n +} + // principalSuite holds the target's sessions and installations, so each case // makes its own grants and can run alone. type principalSuite struct { @@ -487,7 +620,9 @@ type principalSuite struct { f SolutionPrincipalCase ctx context.Context sessions map[string]protoreflect.Message - installed map[string]protoreflect.Message // organisation/solution → installation + installed map[string]protoreflect.Message // organization/solution → installation + // storeGeneration is the store's next delivery generation. + storeGeneration uint64 } func (sp *principalSuite) session(t *testing.T, token string) protoreflect.Message { @@ -518,7 +653,7 @@ func (sp *principalSuite) admin(org string) string { return sp.f.Admin } -// ensure installs s at org once, with the agent's role editor. +// ensure installs s at org once, with the agent's role author. func (sp *principalSuite) ensure(t *testing.T, org string, s SolutionUnderTest) protoreflect.Message { t.Helper() if inst, ok := sp.installed[org+"/"+s.Solution]; ok { @@ -526,7 +661,7 @@ func (sp *principalSuite) ensure(t *testing.T, org string, s SolutionUnderTest) } admin := sp.admin(org) out := sp.c.must(t, sp.as(t, admin), "host.installation.v1.InstallationService", "Install", map[string]any{ - "organization_id": org, "target": s.Solution, "role": "editor", "owner_person_id": sp.personID(t, admin)}) + "organization_id": org, "target": s.Solution, "role": "author", "owner_person_id": sp.personID(t, admin)}) if enumName(out, "status") != "STATUS_ACTIVE" { t.Fatalf("%s at %s is %s; the host's install step must converge for the suite's solutions", s.Solution, org, enumName(out, "status")) } @@ -568,16 +703,13 @@ func (sp *principalSuite) revoke(t *testing.T, s SolutionUnderTest, role string, t.Helper() sp.roleAs(t, sp.f.Admin, "RevokeRole", sp.f.Organization, s, role, subject) } -func (sp *principalSuite) roleIn(t *testing.T, caller, org string, s SolutionUnderTest, role string, subject map[string]any) { - t.Helper() - sp.roleAs(t, caller, "GrantRole", org, s, role, subject) -} -// roleGrants reads ListRoleGrants as " person " or " team ". -func (sp *principalSuite) roleGrants(t *testing.T, s SolutionUnderTest) []string { +// roleGrants reads ListRoleGrants, as caller, as " person " or +// " team ". +func (sp *principalSuite) roleGrants(t *testing.T, caller, org string, s SolutionUnderTest) []string { t.Helper() - out := sp.c.must(t, sp.as(t, sp.f.Admin), "host.installation.v1.InstallationService", "ListRoleGrants", map[string]any{ - "organization_id": sp.f.Organization, "installation_id": str(sp.ensure(t, sp.f.Organization, s), "id")}) + out := sp.c.must(t, sp.as(t, caller), "host.installation.v1.InstallationService", "ListRoleGrants", map[string]any{ + "organization_id": org, "installation_id": str(sp.ensure(t, org, s), "id")}) var grants []string for _, g := range listMsgs(out, "grants") { if id := str(g, "person_id"); id != "" { @@ -792,3 +924,82 @@ func listStrings(m protoreflect.Message, name string) []string { } return out } + +// solutionPrincipal reports whether the published contract is the target's. +// Run's cases that the target supersedes then assert the target's model, so +// one host passes both Run and RunSolutionPrincipalTarget. +func (c *client) solutionPrincipal() bool { + return c.hasElement("host.installation.v1.Installation", "principal_id") +} + +// noRole is the reason a person holding no role on a solution is refused. +func (c *client) noRole() string { + if c.solutionPrincipal() { + return "holds no role" + } + return "holds nothing" +} + +// installationOwner is the principal owning data reached through installation +// id at f.Organization: its principal_id under the target contract, "" before +// it (the earlier model's scopes name no owner). +func (c *client) installationOwner(t *testing.T, ctx context.Context, f Fixture, match func(protoreflect.Message) bool) string { + t.Helper() + if !c.solutionPrincipal() { + return "" + } + admin := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.AdminToken}) + list := c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "List", map[string]any{"organization_id": f.Organization}) + for _, inst := range listMsgs(list, "installations") { + if match(inst) { + return str(inst, "principal_id") + } + } + t.Fatal("required conformance fixture missing: the installation the case reaches") + return "" +} + +func (c *client) solutionOwner(t *testing.T, ctx context.Context, f Fixture, solution string) string { + return c.installationOwner(t, ctx, f, func(inst protoreflect.Message) bool { return str(inst, "target") == solution }) +} + +func (c *client) installationOwnerByID(t *testing.T, ctx context.Context, f Fixture, id string) string { + return c.installationOwner(t, ctx, f, func(inst protoreflect.Message) bool { return str(inst, "id") == id }) +} + +// ownedBy selects ask's resources by owner, when the contract has owners. +func ownedBy(ask map[string]any, owner string) map[string]any { + if owner != "" { + ask["resource_owner_id"] = owner + } + return ask +} + +// exactScopeOwnedBy is exactEffectiveScope, owner-selected when owner is set. +func exactScopeOwnedBy(t *testing.T, wc protoreflect.Message, kind string, actions, ids []string, owner string) { + t.Helper() + if owner == "" { + exactEffectiveScope(t, wc, kind, actions, ids) + return + } + exactOwnedScopeIDs(t, wc, kind, actions, owner, ids) +} + +// heldOwner is the owner of what the session holds on kind at audience under +// the solution-principal contract (a solution's principal), "" before it, at a +// module audience, or when the person holds nothing there. +func (c *client) heldOwner(ctx context.Context, sess protoreflect.Message, audience, kind string) string { + if !c.solutionPrincipal() { + return "" + } + out, err := c.call(as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": audience}) + if err != nil { + return "" + } + for _, scope := range listMsgs(msg(out, "context"), "authority_scopes") { + if str(scope, "resource_kind") == kind { + return str(scope, "resource_owner_id") + } + } + return "" +} diff --git a/libraries/conformance/go/solution_roles.go b/libraries/conformance/go/solution_roles.go index 2da9bf21..08c8ba27 100644 --- a/libraries/conformance/go/solution_roles.go +++ b/libraries/conformance/go/solution_roles.go @@ -43,6 +43,10 @@ func (c *client) solutionRoleCases(t *testing.T, ctx context.Context, f Fixture) own := scopeAsk(r.OwnKind, r.OwnActions, nil) viewer := scopeAsk(r.OwnKind, []string{"read"}, nil) consumed := scopeAsk(r.ConsumedKind, []string{r.ConsumedAction}, nil) + // Under the solution-principal contract every scope a person holds + // through the solution is owned by its principal (RunSolutionPrincipalTarget). + owner := c.solutionOwner(t, ctx, f, r.Solution) + asked := ownedBy(scopeAsk(r.ConsumedKind, []string{r.ConsumedAction}, nil), owner) // Replaying the identical signed delivery is intentional: each behaviour // can run alone, and no earlier test's success is its precondition. bindings := []solutionhost.AuthorityBinding{{ID: r.Binding, Audience: r.Audience, Operations: map[string][]string{ @@ -61,6 +65,9 @@ func (c *client) solutionRoleCases(t *testing.T, ctx context.Context, f Fixture) map[string]any{"method": "GET /open/{path=**}", "any_checked_caller": true}, }, } + if c.solutionPrincipal() { + doc["owner_aware"] = true + } if _, err := c.deliver(ctx, f, r.Solution, "solution", r.Generation, []string{r.OwnKind}, doc, 0, bindings...); err != nil { t.Fatalf("deliver solution roles and ceiling: %v", err) } @@ -74,36 +81,36 @@ func (c *client) solutionRoleCases(t *testing.T, ctx context.Context, f Fixture) if err != nil { t.Fatal(err) } - exactScope(t, msg(out, "context"), "authority_scopes", r.OwnKind, []string{"read"}, nil) + exactScopeOwnedBy(t, msg(out, "context"), r.OwnKind, []string{"read"}, nil, owner) c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": r.Solution}) case names[1]: - _, err := person(r.Audience, "", []any{consumed}) + _, err := person(r.Audience, "", []any{asked}) refused(t, err, codes.PermissionDenied, "beyond") root, err := person(r.Solution, "", nil) if err != nil { t.Fatal(err) } - out := c.must(t, ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{"parent_token": str(root, "token"), "actor": map[string]any{"credential": r.Credential}, "operation_binding_id": r.Binding, "audience": r.Audience, "scopes": []any{consumed}}) + out := c.must(t, ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{"parent_token": str(root, "token"), "actor": map[string]any{"credential": r.Credential}, "operation_binding_id": r.Binding, "audience": r.Audience, "scopes": []any{asked}}) wc := msg(out, "context") - exactEffectiveScope(t, wc, r.ConsumedKind, []string{r.ConsumedAction}, nil) + exactScopeOwnedBy(t, wc, r.ConsumedKind, []string{r.ConsumedAction}, nil, owner) if str(wc, "owner_principal_id") != str(sess, "person_id") || str(wc, "tenant_id") != f.Organization || str(msg(wc, "operation_binding"), "binding_id") != f.Organization+"/"+r.Solution+"/"+r.Binding { t.Fatal("solution child lost owner, tenant or canonical binding identity") } c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": r.Audience}) case names[2]: - out, err := person(r.Solution, r.Binding, []any{consumed}) + out, err := person(r.Solution, r.Binding, []any{asked}) if err != nil { t.Fatal(err) } - exactEffectiveScope(t, msg(out, "context"), r.ConsumedKind, []string{r.ConsumedAction}, nil) + exactScopeOwnedBy(t, msg(out, "context"), r.ConsumedKind, []string{r.ConsumedAction}, nil, owner) case names[3]: outsider := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": r.Outsider}) _, err := c.call(as(ctx, outsider), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": r.Solution}) - refused(t, err, codes.PermissionDenied, "holds nothing") + refused(t, err, codes.PermissionDenied, c.noRole()) _, err = c.call(as(ctx, outsider), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": r.Solution, "operation_binding_id": r.Binding}) - refused(t, err, codes.PermissionDenied, "holds nothing") + refused(t, err, codes.PermissionDenied, c.noRole()) case names[4]: - _, err := person(r.Solution, r.Binding, []any{scopeAsk(r.ConsumedKind, []string{r.ForbiddenAction}, nil)}) + _, err := person(r.Solution, r.Binding, []any{ownedBy(scopeAsk(r.ConsumedKind, []string{r.ForbiddenAction}, nil), owner)}) refused(t, err, codes.PermissionDenied, "beyond") // A correctly signed role cannot manufacture an undeclared permission. doc["roles"] = []any{map[string]any{"name": "installer", "scopes": []any{own}}, map[string]any{"name": "viewer", "scopes": []any{scopeAsk(r.ConsumedKind, []string{r.ForbiddenAction}, nil)}}} diff --git a/libraries/conformance/go/solution_tools.go b/libraries/conformance/go/solution_tools.go index 4730653a..61a60213 100644 --- a/libraries/conformance/go/solution_tools.go +++ b/libraries/conformance/go/solution_tools.go @@ -14,10 +14,13 @@ type SolutionToolsCase struct { Token, Outsider, Module, Credential, Binding, Audience, Kind, Action, Method string } -func (c *client) solutionTools(t *testing.T, ctx context.Context, f SolutionToolsCase) { +func (c *client) solutionTools(t *testing.T, ctx context.Context, fixture Fixture, f SolutionToolsCase) { + // Under the solution-principal contract a person reaches the solution's + // data as its principal's: the ask is owner-selected. + owner := c.solutionOwner(t, ctx, fixture, f.Audience) for _, token := range []string{f.Token, f.Outsider} { sess := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": token}) - read := []any{map[string]any{"resource_kind": f.Kind, "actions": []any{f.Action}}} + read := []any{ownedBy(map[string]any{"resource_kind": f.Kind, "actions": []any{f.Action}}, owner)} // Row 15 proves the binding contributes its scope to person issuance. root, err := c.call(as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": f.Module, "operation_binding_id": f.Binding}) if token == f.Outsider { diff --git a/libraries/hop/go/owner_target_test.go b/libraries/hop/go/owner_target_test.go index 869d57fa..fe9fcc3e 100644 --- a/libraries/hop/go/owner_target_test.go +++ b/libraries/hop/go/owner_target_test.go @@ -49,4 +49,11 @@ func TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind(t *testing.T) { if id.Claims.Allows("documents", "read", "") || id.Claims.Allows("documents", "read", "any") { t.Fatal("target: Claims.Allows admits an owner-selected scope without the resource's owner") } + // The scope is not stripped: the module admits the owner's resources. + owned, ok := any(id.Claims).(interface { + AllowsOwned(kind, action, resourceID, ownerID string) bool + }) + if !ok || !owned.AllowsOwned("documents", "read", "any", "principal-u") || owned.AllowsOwned("documents", "read", "any", "principal-v") { + t.Fatal("target: the hop's claims must admit the owner's resource and refuse another owner's") + } } diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index 796c8a34..6e1751e9 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -279,7 +279,7 @@ func Bare(t *testing.T) *Kit { // manifests from their own workloads. // robin and assistant are declared for the artifact tests, which compose // them themselves. - for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-store", "principal-legacy-store"} { + for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store"} { launcher.Declare(Workload(module)) } _, signing, err := ed25519.GenerateKey(rand.Reader) diff --git a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go index 9d5b9b64..abdf8d20 100644 --- a/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go @@ -44,6 +44,10 @@ func TestTheLocalAuthorityMeetsTheSolutionPrincipalTarget(t *testing.T) { name: principal-store-reader scopes: - {kind: principal.collection, actions: [read], resources: [{id: store-x}]} + - organization: acme + name: principal-note-reader + scopes: + - {kind: principal-a.note, actions: [read]} people: - {id: store-administrator, fixture_token: fixture-store-administrator, organization: acme, roles: [principal-store-admin]} - {id: store-reader, fixture_token: fixture-store-reader, organization: acme, roles: [principal-store-reader]} @@ -51,6 +55,7 @@ people: - {id: principal-team-member, fixture_token: fixture-principal-team-member, organization: acme, roles: [], teams: [principal-team]} - {id: principal-team-leaver, fixture_token: fixture-principal-team-leaver, organization: acme, roles: [], teams: [principal-team]} - {id: principal-org-leaver, fixture_token: fixture-principal-org-leaver, organization: acme, roles: []} + - {id: principal-note-reader, fixture_token: fixture-principal-note-reader, organization: acme, roles: [principal-note-reader]} `)); err != nil { t.Fatal(err) } @@ -84,11 +89,13 @@ people: Organization: "acme", OtherOrganization: "globex", Generation: 1, A: conformance.SolutionUnderTest{Solution: "principal-a", Credential: k.Credential(t, "principal-a")}, B: conformance.SolutionUnderTest{Solution: "principal-b", Credential: k.Credential(t, "principal-b")}, + C: conformance.SolutionUnderTest{Solution: "principal-c", Credential: k.Credential(t, "principal-c")}, Store: "principal-store", StoreCredential: k.Credential(t, "principal-store"), Kind: "principal.collection", LegacyStore: "principal-legacy-store", LegacyStoreCredential: k.Credential(t, "principal-legacy-store"), LegacyKind: "principal.legacy", Admin: "fixture-alex", Viewer: "fixture-maya", Editor: "fixture-remi", Member: "fixture-dev", Installer: "fixture-principal-installer", Team: "principal-team", TeamMember: "fixture-principal-team-member", TeamLeaver: "fixture-principal-team-leaver", OrgLeaver: "fixture-principal-org-leaver", - Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", OtherAdmin: "fixture-zoe", OtherMember: "fixture-omar", + Administrator: "fixture-store-administrator", Reader: "fixture-store-reader", NoteReader: "fixture-principal-note-reader", + OtherAdmin: "fixture-zoe", OtherMember: "fixture-omar", OtherTeam: "ops", Existing: "store-x", Uninstall: func(_ context.Context, id string) error { return k.State.Revoke(id) }, RemoveFromTeam: func(context.Context, string, string) error { return notYet("remove a person from a team") }, From 433f8efa89d11747a9ac55e5e229ecbbc6be4872 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:48:17 -0400 Subject: [PATCH 5/5] test: the solution-principal target, revised for spec review r95 N1: GrantRole, RevokeRole and ListRoleGrants naming the caller's own organization with another organization's installation are refused "organization" (for the administrator and the installer). N2: the Python owner target authenticates a signed context, so the decode path that projects scopes must keep the owner. Coordinator decisions in the model and cases: an uninstall clears role grants; a new grant leaves existing capabilities valid; invalidation is permanent (re-declaring owner_aware revives nothing); a module's hop into a non-owner-aware audience is refused; Go Claims.Scopes() keeps each scope's resource owner (TestScopesKeepTheirResourceOwner); Run's staleness branch revokes and restores the viewer's direct roles exactly and requires the fixture viewer to hold them only directly. Co-Authored-By: Claude Opus 5.5 --- .../go/workcontext/owner_target_test.go | 50 +++++++++++++++ .../python/tests/test_owner_target.py | 35 +++++++---- libraries/conformance/go/README.md | 17 +++-- libraries/conformance/go/conformance.go | 39 +++++++++--- .../conformance/go/solution_principal.go | 63 +++++++++++++------ 5 files changed, 160 insertions(+), 44 deletions(-) diff --git a/libraries/authority-client/go/workcontext/owner_target_test.go b/libraries/authority-client/go/workcontext/owner_target_test.go index 97586d42..b18a69ed 100644 --- a/libraries/authority-client/go/workcontext/owner_target_test.go +++ b/libraries/authority-client/go/workcontext/owner_target_test.go @@ -69,3 +69,53 @@ func TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner(t *testing.T) { } } } + +// ownerOf reads WorkScopeV1 field 4 from scope's encoding, known or not. +func ownerOf(t *testing.T, scope *basev0.WorkScopeV1) string { + t.Helper() + raw, err := proto.Marshal(scope) + if err != nil { + t.Fatal(err) + } + for len(raw) > 0 { + number, kind, n := protowire.ConsumeTag(raw) + if n < 0 { + t.Fatal("unreadable scope") + } + raw = raw[n:] + if number == 4 && kind == protowire.BytesType { + value, m := protowire.ConsumeString(raw) + if m < 0 { + t.Fatal("unreadable owner") + } + return value + } + m := protowire.ConsumeFieldValue(number, kind, raw) + if m < 0 { + t.Fatal("unreadable scope") + } + raw = raw[m:] + } + return "" +} + +// TARGET: Scopes() never hands a module an owner-selected scope as the whole +// kind: each it returns keeps its resource owner. +func TestScopesKeepTheirResourceOwner(t *testing.T) { + if os.Getenv("AUTHORITY_TARGET") != "solution-principal" { + t.Skip("target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal") + } + c := body(t, "session") + c.AuthorityScopes = []*basev0.WorkScopeV1{ownedScope(t, "record", "read", "principal-u"), {ResourceKind: "plain", Actions: []string{"read"}}} + scopes := read(t, c).Scopes() + if len(scopes) != 2 { + t.Fatalf("target: Scopes() returned %d scopes, want 2", len(scopes)) + } + for _, scope := range scopes { + want := map[string]string{"record": "principal-u", "plain": ""}[scope.GetResourceKind()] + if got := ownerOf(t, scope); got != want { + t.Fatalf("target: Scopes() returned %s owned by %q, want %q", scope.GetResourceKind(), got, want) + } + } +} + diff --git a/libraries/authority-client/python/tests/test_owner_target.py b/libraries/authority-client/python/tests/test_owner_target.py index 573b6d64..d3c3e708 100644 --- a/libraries/authority-client/python/tests/test_owner_target.py +++ b/libraries/authority-client/python/tests/test_owner_target.py @@ -1,30 +1,43 @@ """TARGET (solution principal; conformance RunSolutionPrincipalTarget). Run with AUTHORITY_TARGET=solution-principal. A module served in Python never -reads an owner-selected scope as the whole kind: the owner-blind ``allows`` -refuses it, and ``allows_owned`` admits a resource only for its owner (an -owner with ids only when both match; a plain scope names resources, not -owners). +reads an owner-selected scope as the whole kind. The claims are built through +``authenticate`` from a signed context, so the decode path that projects +scopes keeps the owner: the owner-blind ``allows`` refuses an owner-selected +scope, and ``allows_owned`` admits a resource only for its owner (an owner +with ids only when both match; a plain scope names resources, not owners). """ import os import pytest -from authority_client.work_context import Claims, Scope +from authority_client._gen.host.authority.v1.work_context_pb2 import WorkScopeV1 +from authority_client.work_context import Unauthenticated, authenticate + +from test_work_context import PROOF, body, sign, stamped, trust_for pytestmark = pytest.mark.skipif(os.environ.get("AUTHORITY_TARGET") != "solution-principal", reason="target contract, not yet implemented: run with AUTHORITY_TARGET=solution-principal") -def owned(kind, owner, *ids): - try: - return Scope(kind, ("read",), tuple(ids), resource_owner_id=owner) - except TypeError as e: - pytest.fail(f"target: Scope has no resource_owner_id: {e}") +def owned(context, kind, owner, *ids): + """Adds {kind, [read], ids, resource_owner_id: owner}, the owner written as + WorkScopeV1 field 4 until the generated code declares it.""" + raw = WorkScopeV1(resource_kind=kind, actions=["read"], resource_ids=list(ids)).SerializeToString() + value = owner.encode() + context.authority_scopes.add().MergeFromString(raw + bytes([(4 << 3) | 2, len(value)]) + value) def test_claims_admit_an_owner_selected_scope_only_for_its_owner(): - claims = Claims("store", (owned("record", "principal-u"), owned("note", "principal-u", "n1"), Scope("plain", ("read",), ()))) + context = body("session") + del context.authority_scopes[:] + owned(context, "record", "principal-u") + owned(context, "note", "principal-u", "n1") + context.authority_scopes.add(resource_kind="plain", actions=["read"]) + try: + claims = authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) + except Unauthenticated as e: + pytest.fail(f"target: a context with an owner-selected scope must authenticate: {e}") assert not claims.allows("record", "read") and not claims.allows("record", "read", "r1") and not claims.allows("note", "read", "n1"), \ "target: the owner-blind allows admits an owner-selected scope" allows_owned = getattr(claims, "allows_owned", None) diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 572132dc..41c7f67a 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -80,7 +80,11 @@ implements it. The model, in full, is the doc comment on - the solution's roles are levels on U's data, and role names carry no rank; they are granted, revoked (both idempotent) and listed by an administrator of the installation's organization or a holder of that installation's - installer role (`GrantRole`, `RevokeRole`, `ListRoleGrants`); + installer role (`GrantRole`, `RevokeRole`, `ListRoleGrants`); a request + naming another organization's installation is refused; +- a new grant leaves existing capabilities valid; a revoke, leaving a team or + the organization, an uninstall or a withdrawn `owner_aware` invalidates them + permanently; an uninstall clears the installation's role grants; - a capability through the solution, a person's or U's own, is confined to U's data by owner-selected scopes (`WorkScopeV1.resource_owner_id`, field 4), issued only to audiences declaring owner-aware checking, solutions included; @@ -104,11 +108,11 @@ then runs these cases, each making its own grants: 4. a viewer reads exactly what the principal owns, and nothing a person, an installation or another principal owns 5. a route requiring the kind admits an owner-selected scope; the module enforces the owner 6. a capability through the solution is confined to the principal's data; the person's own grants never flow in (the store's kind and the solution's own kind) -7. every audience receiving owner-selected scopes declares owner-aware checking, solutions included -8. withdrawing owner-aware checking refuses the owner-selected capabilities issued before +7. every audience receiving owner-selected scopes declares owner-aware checking, solutions included (a person's root, a solution's child, the agent, and a module's hop) +8. withdrawing owner-aware checking refuses the owner-selected capabilities issued before, and re-declaring it revives none 9. a resource created through the solution is owned by its principal, the person its author 10. a role writes when its scopes hold write, whatever it is called; a viewer cannot -11. an administrator of the organization or the installation's installer grants, revokes and lists; anyone else (another organization's administrator, another installation's installer), and every malformed grant, is refused by name; grants are idempotent +11. an administrator of the organization or the installation's installer grants, revokes and lists; anyone else (another organization's administrator, another installation's installer, a request naming another organization's installation), and every malformed grant, is refused by name; grants are idempotent 12. another installation's and another organization's members never reach the principal's data 13. a child never widens the owner, the actions or the ids it narrowed to 14. an existing resource joins the principal only by a transfer from the organization or its person, administered on that resource through the person's own grants, by a holder of write on the principal's data (fourteen named refusals) @@ -120,7 +124,8 @@ then runs these cases, each making its own grants: Library targets pin the modules' side: `libraries/hop/go` `TestAnOwnerSelectedScopeNeverReadsAsTheWholeKind`, `libraries/authority-client/go/workcontext` -`TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner`, +`TestClaimsAdmitAnOwnerSelectedScopeOnlyForItsOwner` and +`TestScopesKeepTheirResourceOwner`, `libraries/authority-client/python/tests/test_owner_target.py` and `libraries/authority-client/typescript/tests/owner-target.test.mjs`. @@ -134,7 +139,7 @@ one, and the target's model once it carries `Installation.principal_id` | --- | --- | | `conformance.go` an installation acts for itself | its owner is `principal_id` (`installationPrincipal`) | | `conformance.go` the permission matrix | a row at a solution audience asks within the owner the person holds there | -| `conformance.go` a callee refuses what does not verify (staleness) | the viewer's own role is revoked and restored (`RevokeRole`), not a team exposure | +| `conformance.go` a callee refuses what does not verify (staleness) | the viewer's direct roles are revoked and restored exactly (`RevokeRole`), not a team exposure; the fixture viewer holds them only directly | | `conformance.go` `applyManifest` | a solution delivery declares `owner_aware` | | `solution_roles.go` [0] own audience, [1] consumed child, [2] binding root, [4] binding ceiling | owner-selected asks and scopes; the delivery declares `owner_aware` | | `solution_roles.go` [3] no solution role | refused "holds no role" | diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index 45240cb3..7008b7a6 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -8,6 +8,7 @@ package conformance import ( "context" + "slices" "crypto/sha256" "encoding/hex" @@ -414,16 +415,38 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": token, "audience": f.Audience}) if c.solutionPrincipal() { // Under the solution-principal contract a grant moves only its - // subject's authority: the callee refuses the capability whose own - // role was revoked, then the fixture's grant is restored. + // subject's authority: the callee refuses a capability once its + // person's roles are revoked. The fixture's grants are restored + // exactly, and a viewer reaching the installation through a team + // would keep access, so the fixture must not grant one. viewer := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.ViewerToken}) - role := map[string]any{"organization_id": f.Organization, "installation_id": f.InstalledSolution, "role": "viewer", "person_id": str(viewer, "person_id")} - c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "GrantRole", role) - held := str(c.must(t, as(ctx, viewer), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": f.InstalledAudience}), "token") - c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "RevokeRole", role) - _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": held, "audience": f.InstalledAudience}) + installation := map[string]any{"organization_id": f.Organization, "installation_id": f.InstalledSolution} + held := c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "ListRoleGrants", installation) + teams := listStrings(viewer, "team_ids") + var direct []string + for _, g := range listMsgs(held, "grants") { + if slices.Contains(teams, str(g, "team_id")) { + t.Fatalf("required conformance fixture invalid: the staleness probe needs the viewer to hold %s's roles only directly; team %s holds %s", f.InstalledSolution, str(g, "team_id"), str(g, "role")) + } + if str(g, "person_id") == str(viewer, "person_id") { + direct = append(direct, str(g, "role")) + } + } + role := func(name string) map[string]any { + return map[string]any{"organization_id": f.Organization, "installation_id": f.InstalledSolution, "role": name, "person_id": str(viewer, "person_id")} + } + if len(direct) == 0 { + c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "GrantRole", role("viewer")) + } + token := str(c.must(t, as(ctx, viewer), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": f.InstalledAudience}), "token") + for _, name := range append(slices.Clone(direct), "viewer") { + c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "RevokeRole", role(name)) + } + _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": token, "audience": f.InstalledAudience}) refused(t, err, codes.PermissionDenied, "revoked") - c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "GrantRole", role) + for _, name := range direct { + c.must(t, as(ctx, admin), "host.installation.v1.InstallationService", "GrantRole", role(name)) + } return } // A team the installation already reaches moves nothing, as a second run diff --git a/libraries/conformance/go/solution_principal.go b/libraries/conformance/go/solution_principal.go index 34f6bbe6..ad6b7642 100644 --- a/libraries/conformance/go/solution_principal.go +++ b/libraries/conformance/go/solution_principal.go @@ -24,9 +24,15 @@ import ( // permission levels on U's data, and role names carry no rank: what a role // may do is the scopes it declares. Granting a person or a team a role // (GrantRole) is the access, revoking it (RevokeRole) ends it at once, both -// idempotent, and ListRoleGrants answers who holds which. An administrator of the -// installation's organization, or a holder of the installer role on that -// installation, grants, revokes and lists; nobody else. +// idempotent, and ListRoleGrants answers who holds which. A new grant leaves +// every existing capability valid; only a revoke, leaving a team or the +// organization, an uninstall or a withdrawn owner_aware invalidates, and that +// is permanent: only fresh capabilities work afterwards. An uninstall clears +// the installation's role grants, so a reinstall needs re-grants. An +// administrator of the installation's organization, or a holder of the +// installer role on that installation, grants, revokes and lists; nobody +// else, and a request naming an installation of another organization is +// refused naming the organization. // // A capability issued through the solution, to a person or to U itself, is // CONFINED to U's data: owner-selected scopes {kind, actions, @@ -274,13 +280,21 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des sp.grant(t, C, "viewer", sp.person(t, f.Viewer)) _, err = sp.root(t, f.Viewer, C) refused(t, err, codes.PermissionDenied, "owner-aware") + // A module's hop counts as much as a solution's: the store carrying + // owner-selected scopes on to the legacy store is refused. + atStore := sp.must(t)(sp.through(t, f.Viewer, A, "store")) + _, err = sp.hopTo(t, atStore, "legacy", f.LegacyStore) + refused(t, err, codes.PermissionDenied, "owner-aware") }) t.Run("withdrawing owner-aware checking refuses the owner-selected capabilities issued before", func(t *testing.T) { sp.grant(t, A, "viewer", sp.person(t, f.Viewer)) before := sp.must(t)(sp.through(t, f.Viewer, A, "store")) sp.deliverStore(t, false) refused(t, sp.check(before), codes.PermissionDenied, "owner-aware") + // Re-declaring it revives nothing: only fresh capabilities work. sp.deliverStore(t, true) + refused(t, sp.check(before), codes.PermissionDenied, "owner-aware") + sp.must(t)(sp.through(t, f.Viewer, A, "store")) }) t.Run("a resource created through the solution is owned by its principal, the person its author", func(t *testing.T) { sp.grant(t, A, "author", sp.person(t, f.Editor)) @@ -315,6 +329,7 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des sp.roleAs(t, f.Installer, "RevokeRole", f.Organization, A, "viewer", sp.person(t, f.Member)) sp.roleGrants(t, f.Installer, f.Organization, A) instA, instB := str(sp.ensure(t, f.Organization, A), "id"), str(sp.ensure(t, f.Organization, B), "id") + instElsewhere := str(sp.ensure(t, f.OtherOrganization, A), "id") grant := func(installation, role string, subject map[string]any) map[string]any { out := map[string]any{"organization_id": f.Organization, "installation_id": installation, "role": role} for k, v := range subject { @@ -329,20 +344,24 @@ func RunSolutionPrincipalTarget(t *testing.T, conn grpc.ClientConnInterface, des code codes.Code reason string }{ - "a viewer granting itself installer": {f.Viewer, "GrantRole", grant(instA, "installer", sp.person(t, f.Viewer)), codes.PermissionDenied, "may not grant"}, - "a viewer revoking the installer": {f.Viewer, "RevokeRole", grant(instA, "installer", sp.person(t, f.Installer)), codes.PermissionDenied, "may not grant"}, - "a viewer listing grants": {f.Viewer, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instA}, codes.PermissionDenied, "may not"}, - "another organization's administrator granting": {f.OtherAdmin, "GrantRole", grant(instA, "viewer", member), codes.PermissionDenied, "may not grant"}, - "another organization's administrator revoking": {f.OtherAdmin, "RevokeRole", grant(instA, "viewer", sp.person(t, f.Viewer)), codes.PermissionDenied, "may not grant"}, - "another organization's administrator listing": {f.OtherAdmin, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instA}, codes.PermissionDenied, "may not"}, - "A's installer granting on B": {f.Installer, "GrantRole", grant(instB, "author", sp.person(t, f.Installer)), codes.PermissionDenied, "may not grant"}, - "A's installer revoking on B": {f.Installer, "RevokeRole", grant(instB, "viewer", member), codes.PermissionDenied, "may not grant"}, - "another organization's id": {f.Admin, "GrantRole", map[string]any{"organization_id": f.OtherOrganization, "installation_id": instA, "role": "viewer", "person_id": sp.personID(t, f.Member)}, codes.PermissionDenied, "organization"}, - "an undeclared role": {f.Admin, "GrantRole", grant(instA, "owner", member), codes.NotFound, "declares no role"}, - "both a person and a team": {f.Admin, "GrantRole", grant(instA, "viewer", map[string]any{"person_id": sp.personID(t, f.Member), "team_id": f.Team}), codes.InvalidArgument, "exactly one"}, - "neither a person nor a team": {f.Admin, "GrantRole", grant(instA, "viewer", nil), codes.InvalidArgument, "exactly one"}, - "a person of another organization": {f.Admin, "GrantRole", grant(instA, "viewer", sp.person(t, f.OtherMember)), codes.PermissionDenied, "not a member"}, - "a team of another organization": {f.Admin, "GrantRole", grant(instA, "viewer", map[string]any{"team_id": f.OtherTeam}), codes.PermissionDenied, "not a team of"}, + "a viewer granting itself installer": {f.Viewer, "GrantRole", grant(instA, "installer", sp.person(t, f.Viewer)), codes.PermissionDenied, "may not grant"}, + "a viewer revoking the installer": {f.Viewer, "RevokeRole", grant(instA, "installer", sp.person(t, f.Installer)), codes.PermissionDenied, "may not grant"}, + "a viewer listing grants": {f.Viewer, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instA}, codes.PermissionDenied, "may not"}, + "another organization's administrator granting": {f.OtherAdmin, "GrantRole", grant(instA, "viewer", member), codes.PermissionDenied, "may not grant"}, + "another organization's administrator revoking": {f.OtherAdmin, "RevokeRole", grant(instA, "viewer", sp.person(t, f.Viewer)), codes.PermissionDenied, "may not grant"}, + "another organization's administrator listing": {f.OtherAdmin, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instA}, codes.PermissionDenied, "may not"}, + "A's installer granting on B": {f.Installer, "GrantRole", grant(instB, "author", sp.person(t, f.Installer)), codes.PermissionDenied, "may not grant"}, + "A's installer revoking on B": {f.Installer, "RevokeRole", grant(instB, "viewer", member), codes.PermissionDenied, "may not grant"}, + "another organization's id": {f.Admin, "GrantRole", map[string]any{"organization_id": f.OtherOrganization, "installation_id": instA, "role": "viewer", "person_id": sp.personID(t, f.Member)}, codes.PermissionDenied, "organization"}, + "granting on another organization's installation": {f.Admin, "GrantRole", grant(instElsewhere, "viewer", member), codes.PermissionDenied, "organization"}, + "revoking on another organization's installation": {f.Admin, "RevokeRole", grant(instElsewhere, "viewer", sp.person(t, f.OtherMember)), codes.PermissionDenied, "organization"}, + "listing another organization's installation": {f.Admin, "ListRoleGrants", map[string]any{"organization_id": f.Organization, "installation_id": instElsewhere}, codes.PermissionDenied, "organization"}, + "the installer on another organization's installation": {f.Installer, "GrantRole", grant(instElsewhere, "viewer", member), codes.PermissionDenied, "organization"}, + "an undeclared role": {f.Admin, "GrantRole", grant(instA, "owner", member), codes.NotFound, "declares no role"}, + "both a person and a team": {f.Admin, "GrantRole", grant(instA, "viewer", map[string]any{"person_id": sp.personID(t, f.Member), "team_id": f.Team}), codes.InvalidArgument, "exactly one"}, + "neither a person nor a team": {f.Admin, "GrantRole", grant(instA, "viewer", nil), codes.InvalidArgument, "exactly one"}, + "a person of another organization": {f.Admin, "GrantRole", grant(instA, "viewer", sp.person(t, f.OtherMember)), codes.PermissionDenied, "not a member"}, + "a team of another organization": {f.Admin, "GrantRole", grant(instA, "viewer", map[string]any{"team_id": f.OtherTeam}), codes.PermissionDenied, "not a team of"}, } { t.Run(name, func(t *testing.T) { _, err := c.call(sp.as(t, r.caller), "host.installation.v1.InstallationService", r.method, r.fields) @@ -593,7 +612,8 @@ func (sp *principalSuite) deliverStore(t *testing.T, ownerAware bool) { doc["owner_aware"] = true } if _, err := sp.c.deliver(sp.ctx, sp.delivery(), f.Store, "module", sp.storeGeneration, []string{f.Kind}, doc, 0, - solutionhost.AuthorityBinding{ID: "self", Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read", f.Kind + ":write"}}}); err != nil { + solutionhost.AuthorityBinding{ID: "self", Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read", f.Kind + ":write"}}}, + solutionhost.AuthorityBinding{ID: "legacy", Audience: f.LegacyStore, Operations: map[string][]string{"invoke": {f.LegacyKind + ":read"}}}); err != nil { t.Fatalf("deliver %s: %v", f.Store, err) } sp.storeGeneration++ @@ -752,8 +772,13 @@ func (sp *principalSuite) through(t *testing.T, token string, s SolutionUnderTes // hop is the store's own hop on parent, through its self binding. func (sp *principalSuite) hop(t *testing.T, parent protoreflect.Message, scopes ...any) (protoreflect.Message, error) { + return sp.hopTo(t, parent, "self", sp.f.Store, scopes...) +} + +// hopTo is the store's own hop on parent through binding to audience. +func (sp *principalSuite) hopTo(t *testing.T, parent protoreflect.Message, binding, audience string, scopes ...any) (protoreflect.Message, error) { return sp.issue(map[string]any{"parent_token": str(parent, "token"), "actor": map[string]any{"credential": sp.f.StoreCredential}, - "operation_binding_id": "self", "audience": sp.f.Store, "scopes": scopes}) + "operation_binding_id": binding, "audience": audience, "scopes": scopes}) } // agent is the installation of s acting for itself through binding ("" names none).