Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions libraries/authority-client/go/workcontext/owner_target_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

51 changes: 51 additions & 0 deletions libraries/authority-client/python/tests/test_owner_target.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 42 additions & 0 deletions libraries/authority-client/typescript/tests/owner-target.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
});
91 changes: 91 additions & 0 deletions libraries/conformance/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading