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..b18a69ed --- /dev/null +++ b/libraries/authority-client/go/workcontext/owner_target_test.go @@ -0,0 +1,121 @@ +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; 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"), + {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") + } + 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}, + {"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) + } + } +} + +// 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 new file mode 100644 index 00000000..d3c3e708 --- /dev/null +++ b/libraries/authority-client/python/tests/test_owner_target.py @@ -0,0 +1,51 @@ +"""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 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._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(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(): + 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) + 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 aa2680ee..41c7f67a 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -67,3 +67,94 @@ 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. The model, in full, is the doc comment on +`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 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; +- 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, 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 (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, 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) +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` and +`TestScopesKeepTheirResourceOwner`, +`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 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" | +| `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`; `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 eeb5ad89..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" @@ -194,7 +195,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 +364,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 +413,42 @@ 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 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}) + 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") + 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 // 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}) @@ -486,11 +532,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}) @@ -518,7 +567,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 @@ -608,7 +657,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 new file mode 100644 index 00000000..ad6b7642 --- /dev/null +++ b/libraries/conformance/go/solution_principal.go @@ -0,0 +1,1030 @@ +package conformance + +import ( + "context" + "regexp" + "slices" + "strings" + "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 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 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. 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, +// 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) 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, 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, 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 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; 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, 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, 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 + 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. +type SolutionUnderTest struct { + Solution, Credential string +} + +// 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.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", "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 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. 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) + if err != nil { + t.Fatalf("conformance: %v", err) + } + if problems := c.contractProblems(); len(problems) > 0 { + 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 == "" || 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() + 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 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{"author", "editor", "installer", "viewer"}) { + t.Fatalf("the installation's roles are %v, want the solution's declared roles", got) + } + } + if U(t) == V(t) { + 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 organizations 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) + } + }) + 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, "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, 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) { + _, err := sp.root(t, f.Member, A) + refused(t, err, codes.PermissionDenied, "holds no role") + }) + 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, "store")) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, U(t)) + sp.listed(t, out, f.Kind, "read", map[string]string{"*": U(t)}) + 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 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, "store")) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, U(t)) + } + _, 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, "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("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") + refused(t, err, codes.PermissionDenied, "owner-aware") + _, 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") + // 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)) + 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, "store", sp.owned(f.Kind, []string{"create"}, U(t)))) + exactOwnedScope(t, msg(byAgent, "context"), f.Kind, []string{"create"}, 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)) + // "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 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)) + // 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)) + 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 { + out[k] = v + } + return out + } + member := map[string]any{"person_id": sp.personID(t, f.Member)} + for name, r := range map[string]struct { + caller, method string + fields map[string]any + 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"}, + "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) + refused(t, err, r.code, r.reason) + }) + } + }) + 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, "store")) + exactOwnedScope(t, msg(out, "context"), f.Kind, []string{"read"}, V(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.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, "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, "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, "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 + 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 := sp.hop(t, attempt.parent, attempt.ask) + 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 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)) + 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) + } + 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)) + } + }) + } + 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, "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 + 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"}, + "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 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"}, + "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 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, "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") + _, 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") + 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, 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, "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; 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, 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, "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, "store")) + refused(t, sp.check(before), codes.PermissionDenied, "revoked") + 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) + } + refused(t, sp.check(leaving), codes.PermissionDenied, "revoked") + _, err = sp.root(t, f.TeamLeaver, A) + refused(t, err, codes.PermissionDenied, "holds no role") + }) + 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, "store")) + if err := f.RemoveFromOrganization(ctx, sp.personID(t, f.OrgLeaver)); err != nil { + t.Fatal(err) + } + 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, "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, "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") + 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, "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) + } + _, 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, "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"}}}, + 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++ +} + +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 { + c *client + f SolutionPrincipalCase + ctx context.Context + sessions map[string]protoreflect.Message + 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 { + 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 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 { + 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": "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")) + } + 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) +} + +// 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, 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 != "" { + 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.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": binding, "audience": audience, "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 { + t.Fatal(err) + } + 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 + } + if !slices.Contains(lacks, name) { + lacks = append(lacks, name) + } + } + } + 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 +// 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() + 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") + } + 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 +} + +// 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 new file mode 100644 index 00000000..fe9fcc3e --- /dev/null +++ b/libraries/hop/go/owner_target_test.go @@ -0,0 +1,59 @@ +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") + } + // 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 836e015b..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"} { + 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 new file mode 100644 index 00000000..abdf8d20 --- /dev/null +++ b/modules/authority/services/local/code/plugins/registry_test/solution_principal_target_test.go @@ -0,0 +1,108 @@ +// 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" + "errors" + "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), 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 + 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}]} + - 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]} + - {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: []} + - {id: principal-note-reader, fixture_token: fixture-principal-note-reader, organization: acme, roles: [principal-note-reader]} +`)); 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}, + &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", 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", 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") }, + 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", + }) +}