diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 00000000..ac3a5d96 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,56 @@ +name: go + +# Every Go module in the repository is vetted and race-tested on each pull +# request and on main. tools/noproxygate's test is the no-proxy gate: it +# type-checks every module and refuses requests, dials and subprocesses that +# bypass host.HTTPTransport and host.DialOptions. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + modules: + name: find Go modules + runs-on: ubuntu-latest + outputs: + modules: ${{ steps.find.outputs.modules }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - id: find + name: list every go.mod + run: | + modules=$(find . -name go.mod -not -path '*/node_modules/*' -printf '%h\n' | sed 's|^\./||' | sort | jq -R . | jq -cs .) + echo "$modules" + test "$(echo "$modules" | jq length)" -gt 0 + echo "modules=$modules" >> "$GITHUB_OUTPUT" + + test: + name: ${{ matrix.module }} + needs: modules + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ${{ fromJSON(needs.modules.outputs.modules) }} + defaults: + run: + working-directory: ${{ matrix.module }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - name: go vet + run: go vet ./... + - name: go test -race + run: go test -race -count=1 ./... diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index f83f7a6c..1c92f0a4 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -1,7 +1,7 @@ { "schema": "codefly/module-api-contracts/v1", "package": "local", - "version": "0.0.0", + "version": "0.0.1", "endpoints": [ { "service": "local", @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:d2fcb57f3608ea6c57918565db2d83cc69d7e9cebee33edadf2160a3fee8d635", + "digest": "sha256:9fcee9f110613a97881cfdfd035de3b82b7b58cac138e1093a47ba4460436d72", "services": [ { "name": "GatewayService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index a9656334..e2bea10c 100644 Binary files a/libraries/authority-client/contract/contract.binpb and b/libraries/authority-client/contract/contract.binpb differ diff --git a/libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go b/libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go index ef4733af..95f91ca4 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go @@ -543,9 +543,15 @@ type WorkScopeV1 struct { Actions []string `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` // resource_ids narrows the scope to specific resources. Empty means every // resource of resource_kind. - ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` + // resource_owner_id narrows the scope to the resources this principal owns + // (an installed solution's principal). Empty means the scope selects no + // owner. With resource_ids, a resource is covered only when both match. A + // reader that cannot compare a resource's owner must not treat an + // owner-selected scope as covering the whole kind. + ResourceOwnerId string `protobuf:"bytes,4,opt,name=resource_owner_id,json=resourceOwnerId,proto3" json:"resource_owner_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkScopeV1) Reset() { @@ -599,6 +605,13 @@ func (x *WorkScopeV1) GetResourceIds() []string { return nil } +func (x *WorkScopeV1) GetResourceOwnerId() string { + if x != nil { + return x.ResourceOwnerId + } + return "" +} + // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's // effective scopes and must monotonically attenuate the previous hop. type WorkActorV1 struct { @@ -1028,12 +1041,13 @@ const file_host_authority_v1_work_context_proto_rawDesc = "" + "binding_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\tbindingId\x12#\n" + "\brevision\x18\x02 \x01(\x04B\a\xbaH\x042\x02(\x01R\brevision\x12)\n" + - "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"{\n" + + "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"\xc0\x01\n" + "\vWorkScopeV1\x12/\n" + "\rresource_kind\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\fresourceKind\x12\x18\n" + "\aactions\x18\x02 \x03(\tR\aactions\x12!\n" + - "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\"\xd0\x03\n" + + "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\x12C\n" + + "\x11resource_owner_id\x18\x04 \x01(\tB\x17\xbaH\x14r\x12\x18\x80\x042\r^[^\\s\\x00*]*$R\x0fresourceOwnerId\"\xd0\x03\n" + "\vWorkActorV1\x12-\n" + "\fprincipal_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\vprincipalId\x121\n" + diff --git a/libraries/authority-client/go/go.mod b/libraries/authority-client/go/go.mod index cf15718c..66cafb8a 100644 --- a/libraries/authority-client/go/go.mod +++ b/libraries/authority-client/go/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 github.com/codefly-dev/sdk-go v0.4.0 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b google.golang.org/grpc v1.84.0 diff --git a/libraries/authority-client/go/go.sum b/libraries/authority-client/go/go.sum index 5ce30c7c..5e631158 100644 --- a/libraries/authority-client/go/go.sum +++ b/libraries/authority-client/go/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/libraries/authority-client/go/workcontext/claims.go b/libraries/authority-client/go/workcontext/claims.go index c1d2cfb0..6af5ebcb 100644 --- a/libraries/authority-client/go/workcontext/claims.go +++ b/libraries/authority-client/go/workcontext/claims.go @@ -111,12 +111,23 @@ func (c *Claims) Scopes() []*basev0.WorkScopeV1 { // Allows asks for one literal action on one exact resource. An empty resourceID // asks for the whole kind; a resource-limited grant cannot satisfy that request. -// No wildcard expansion, action implication or cross-grant merging occurs. +// No wildcard expansion, action implication or cross-grant merging occurs. It +// names no resource owner, so an owner-selected grant never satisfies it: a +// module whose resources have owners asks AllowsOwned. func (c *Claims) Allows(kind, action, resourceID string) bool { + return c.AllowsOwned(kind, action, resourceID, "") +} + +// AllowsOwned is Allows for a resource owned by ownerID (the owner the +// module's own record names): an owner-selected grant covers it only when it +// names that owner, and, with ids, the id too. A grant naming no owner covers +// it whatever its owner. An empty resourceID with an owner asks for every +// resource that owner holds, the list path. +func (c *Claims) AllowsOwned(kind, action, resourceID, ownerID string) bool { if c == nil || kind == "" || action == "" { return false } - ask := &basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}} + ask := &basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}, ResourceOwnerId: ownerID} if resourceID != "" { ask.ResourceIds = []string{resourceID} } diff --git a/libraries/authority-client/go/workcontext/claims_test.go b/libraries/authority-client/go/workcontext/claims_test.go index 108568f6..fea07c19 100644 --- a/libraries/authority-client/go/workcontext/claims_test.go +++ b/libraries/authority-client/go/workcontext/claims_test.go @@ -25,7 +25,9 @@ type fixture struct { Probes []struct { Kind, Action string ResourceID string `json:"resource_id"` - Allowed bool + // Owner, when the probe names one, asks the owner-aware AllowsOwned. + Owner *string + Allowed bool } } @@ -100,8 +102,12 @@ func TestCoreFixtures(t *testing.T) { } } for _, p := range f.Probes { - if c.Allows(p.Kind, p.Action, p.ResourceID) != p.Allowed { - t.Fatalf("probe %s %s %s", p.Kind, p.Action, p.ResourceID) + got := c.Allows(p.Kind, p.Action, p.ResourceID) + if p.Owner != nil { + got = c.AllowsOwned(p.Kind, p.Action, p.ResourceID, *p.Owner) + } + if got != p.Allowed { + t.Fatalf("probe %s %s %s owner %v", p.Kind, p.Action, p.ResourceID, p.Owner) } } }) diff --git a/libraries/authority-client/go/workcontext/owner_target_test.go b/libraries/authority-client/go/workcontext/owner_target_test.go deleted file mode 100644 index d027b6c2..00000000 --- a/libraries/authority-client/go/workcontext/owner_target_test.go +++ /dev/null @@ -1,120 +0,0 @@ -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/go/workcontext/owner_test.go b/libraries/authority-client/go/workcontext/owner_test.go new file mode 100644 index 00000000..66311bc0 --- /dev/null +++ b/libraries/authority-client/go/workcontext/owner_test.go @@ -0,0 +1,94 @@ +package workcontext_test + +import ( + "testing" + + reader "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext" + + 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}. +func ownedScope(t *testing.T, kind, action, owner string, ids ...string) *basev0.WorkScopeV1 { + t.Helper() + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}, ResourceIds: ids, ResourceOwnerId: owner} +} + +// The owner-selected rows (Allows refusing, AllowsOwned admitting only the +// owner) are the shared fixture's "owner-selected" entry, which TestCoreFixtures +// runs here and the Python and TypeScript readers run on the same rows. + +// 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 "" +} + +// 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) { + 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("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("Scopes() returned %s owned by %q, want %q", scope.GetResourceKind(), got, want) + } + } +} + +// A signed chain whose hop drops or changes the resource owner is a widening: +// it is refused, while a hop narrowing within the owner verifies. +func TestAChainThatDropsOrChangesTheOwnerIsRefused(t *testing.T) { + for name, tc := range map[string]struct { + last *basev0.WorkScopeV1 + ok bool + }{ + "narrowed within the owner": {&basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"read"}, ResourceIds: []string{"r1"}, ResourceOwnerId: "principal-u"}, true}, + "the owner dropped": {&basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"read"}}, false}, + "the owner changed": {&basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"read"}, ResourceOwnerId: "principal-v"}, false}, + } { + t.Run(name, func(t *testing.T) { + c := body(t, "delegated") + owned := &basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"read"}, ResourceOwnerId: "principal-u"} + c.AuthorityScopes = []*basev0.WorkScopeV1{owned} + for _, actor := range c.ActorChain { + actor.GrantedScopes = []*basev0.WorkScopeV1{owned} + } + c.ActorChain[len(c.ActorChain)-1].GrantedScopes = []*basev0.WorkScopeV1{tc.last} + _, err := reader.Authenticate(stampedToken(tokenFor(t, c)), "test-proof", c.GetAudience(), trustFor(c)) + if (err == nil) != tc.ok { + t.Fatalf("authenticate: %v", err) + } + }) + } +} diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 1af6d794..cf3d6440 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -1,6 +1,6 @@ kind: library name: authority-client -version: 0.0.0 +version: 0.0.1 languages: - name: go path: go/ @@ -16,11 +16,11 @@ languages: - '@codefly-dev/authority-client' sources: - package: local - version: 0.0.0 + version: 0.0.1 module: authority service: local endpoint: grpc - contract-digest: sha256:d2fcb57f3608ea6c57918565db2d83cc69d7e9cebee33edadf2160a3fee8d635 + contract-digest: sha256:9fcee9f110613a97881cfdfd035de3b82b7b58cac138e1093a47ba4460436d72 services: - GatewayService generated-by: diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/work_context_pb2.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/work_context_pb2.py index f494f35a..0547fc7b 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/work_context_pb2.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/work_context_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$host/authority/v1/work_context.proto\x12\x11host.authority.v1\"\xaf\n\n\rWorkContextV1\x12\x10\n\x03typ\x18\x01 \x01(\tR\x03typ\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x15\n\x06key_id\x18\x03 \x01(\tR\x05keyId\x12\x16\n\x06issuer\x18\x04 \x01(\tR\x06issuer\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12&\n\x0fnot_before_unix\x18\x06 \x01(\x03R\rnotBeforeUnix\x12$\n\x0eissued_at_unix\x18\x07 \x01(\x03R\x0cissuedAtUnix\x12&\n\x0f\x65xpires_at_unix\x18\x08 \x01(\x03R\rexpiresAtUnix\x12\x14\n\x05nonce\x18\t \x01(\tR\x05nonce\x12\x35\n\x16\x61uthorization_revision\x18\n \x01(\x04R\x15\x61uthorizationRevision\x12#\n\rreplay_policy\x18\x0b \x01(\tR\x0creplayPolicy\x12\x1b\n\ttenant_id\x18\x0c \x01(\tR\x08tenantId\x12,\n\x12owner_principal_id\x18\r \x01(\tR\x10ownerPrincipalId\x12\x17\n\x07task_id\x18\x0e \x01(\tR\x06taskId\x12\x1d\n\nsession_id\x18\x0f \x01(\tR\tsessionId\x12/\n\x11parent_session_id\x18\x10 \x01(\tH\x00R\x0fparentSessionId\x88\x01\x01\x12I\n\x10\x61uthority_scopes\x18\x11 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0f\x61uthorityScopes\x12?\n\x0b\x61\x63tor_chain\x18\x12 \x03(\x0b\x32\x1e.host.authority.v1.WorkActorV1R\nactorChain\x12\x30\n\x14\x61ttribution_team_ids\x18\x13 \x03(\tR\x12\x61ttributionTeamIds\x12&\n\x0cworkspace_id\x18\x14 \x01(\tH\x01R\x0bworkspaceId\x88\x01\x01\x12\"\n\nproject_id\x18\x15 \x01(\tH\x02R\tprojectId\x88\x01\x01\x12\x35\n\x14owner_principal_kind\x18\x16 \x01(\tH\x03R\x12ownerPrincipalKind\x88\x01\x01\x12)\n\x0eowner_agent_id\x18\x17 \x01(\tH\x04R\x0cownerAgentId\x88\x01\x01\x12,\n\x0forganization_id\x18\x19 \x01(\tH\x05R\x0eorganizationId\x88\x01\x01\x12\x43\n\tgrant_hop\x18\x18 \x01(\x0b\x32!.host.authority.v1.WorkGrantHopV1H\x06R\x08grantHop\x88\x01\x01\x12\x31\n\x04seal\x18\x1a \x01(\x0b\x32\x1d.host.authority.v1.WorkSealV1R\x04seal\x12[\n\x11operation_binding\x18\x1b \x01(\x0b\x32).host.authority.v1.WorkOperationBindingV1H\x07R\x10operationBinding\x88\x01\x01\x42\x14\n\x12_parent_session_idB\x0f\n\r_workspace_idB\r\n\x0b_project_idB\x17\n\x15_owner_principal_kindB\x11\n\x0f_owner_agent_idB\x12\n\x10_organization_idB\x0c\n\n_grant_hopB\x14\n\x12_operation_binding\"\xc0\x01\n\nWorkSealV1\x12\'\n\x0fprincipal_epoch\x18\x01 \x01(\x04R\x0eprincipalEpoch\x12\'\n\x0finstallation_id\x18\x02 \x01(\tR\x0einstallationId\x12\x33\n\x15installation_revision\x18\x03 \x01(\x04R\x14installationRevisionJ\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06R\x11\x62uild_incarnationR\x0cimage_digest\"u\n\x16WorkOperationBindingV1\x12\x1d\n\nbinding_id\x18\x01 \x01(\tR\tbindingId\x12\x1a\n\x08revision\x18\x02 \x01(\x04R\x08revision\x12 \n\x0bincarnation\x18\x03 \x01(\x04R\x0bincarnation\"o\n\x0bWorkScopeV1\x12#\n\rresource_kind\x18\x01 \x01(\tR\x0cresourceKind\x12\x18\n\x07\x61\x63tions\x18\x02 \x03(\tR\x07\x61\x63tions\x12!\n\x0cresource_ids\x18\x03 \x03(\tR\x0bresourceIds\"\x88\x03\n\x0bWorkActorV1\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12%\n\x0eprincipal_kind\x18\x02 \x01(\tR\rprincipalKind\x12#\n\rdelegation_id\x18\x03 \x01(\tR\x0c\x64\x65legationId\x12\x45\n\x0egranted_scopes\x18\x04 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\rgrantedScopes\x12\x1e\n\x08\x61gent_id\x18\x05 \x01(\tH\x00R\x07\x61gentId\x88\x01\x01\x12,\n\x0forganization_id\x18\x06 \x01(\tH\x01R\x0eorganizationId\x88\x01\x01\x12\'\n\x0fprincipal_epoch\x18\x07 \x01(\x04R\x0eprincipalEpochB\x0b\n\t_agent_idB\x12\n\x10_organization_idJ\x04\x08\x08\x10\tJ\x04\x08\t\x10\nR\x0cimage_digestR\x11\x62uild_incarnation\"\xf2\x01\n\x0eWorkGrantHopV1\x12\x19\n\x08grant_id\x18\x01 \x01(\tR\x07grantId\x12?\n\tapprovers\x18\x02 \x03(\x0b\x32!.host.authority.v1.WorkApproverV1R\tapprovers\x12\x43\n\rgranted_scope\x18\x03 \x01(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0cgrantedScope\x12\x18\n\x07subject\x18\x04 \x01(\tR\x07subject\x12%\n\x0erequest_digest\x18\x05 \x01(\tR\rrequestDigest\"Z\n\x0eWorkApproverV1\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12%\n\x0eprincipal_kind\x18\x02 \x01(\tR\rprincipalKind\"\xd9\x01\n\x12\x41pprovalRequiredV1\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12G\n\x0frequested_scope\x18\x02 \x01(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0erequestedScope\x12\x18\n\x07subject\x18\x03 \x01(\tR\x07subject\x12%\n\x0erequest_digest\x18\x04 \x01(\tR\rrequestDigest\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udienceB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$host/authority/v1/work_context.proto\x12\x11host.authority.v1\"\xaf\n\n\rWorkContextV1\x12\x10\n\x03typ\x18\x01 \x01(\tR\x03typ\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x15\n\x06key_id\x18\x03 \x01(\tR\x05keyId\x12\x16\n\x06issuer\x18\x04 \x01(\tR\x06issuer\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12&\n\x0fnot_before_unix\x18\x06 \x01(\x03R\rnotBeforeUnix\x12$\n\x0eissued_at_unix\x18\x07 \x01(\x03R\x0cissuedAtUnix\x12&\n\x0f\x65xpires_at_unix\x18\x08 \x01(\x03R\rexpiresAtUnix\x12\x14\n\x05nonce\x18\t \x01(\tR\x05nonce\x12\x35\n\x16\x61uthorization_revision\x18\n \x01(\x04R\x15\x61uthorizationRevision\x12#\n\rreplay_policy\x18\x0b \x01(\tR\x0creplayPolicy\x12\x1b\n\ttenant_id\x18\x0c \x01(\tR\x08tenantId\x12,\n\x12owner_principal_id\x18\r \x01(\tR\x10ownerPrincipalId\x12\x17\n\x07task_id\x18\x0e \x01(\tR\x06taskId\x12\x1d\n\nsession_id\x18\x0f \x01(\tR\tsessionId\x12/\n\x11parent_session_id\x18\x10 \x01(\tH\x00R\x0fparentSessionId\x88\x01\x01\x12I\n\x10\x61uthority_scopes\x18\x11 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0f\x61uthorityScopes\x12?\n\x0b\x61\x63tor_chain\x18\x12 \x03(\x0b\x32\x1e.host.authority.v1.WorkActorV1R\nactorChain\x12\x30\n\x14\x61ttribution_team_ids\x18\x13 \x03(\tR\x12\x61ttributionTeamIds\x12&\n\x0cworkspace_id\x18\x14 \x01(\tH\x01R\x0bworkspaceId\x88\x01\x01\x12\"\n\nproject_id\x18\x15 \x01(\tH\x02R\tprojectId\x88\x01\x01\x12\x35\n\x14owner_principal_kind\x18\x16 \x01(\tH\x03R\x12ownerPrincipalKind\x88\x01\x01\x12)\n\x0eowner_agent_id\x18\x17 \x01(\tH\x04R\x0cownerAgentId\x88\x01\x01\x12,\n\x0forganization_id\x18\x19 \x01(\tH\x05R\x0eorganizationId\x88\x01\x01\x12\x43\n\tgrant_hop\x18\x18 \x01(\x0b\x32!.host.authority.v1.WorkGrantHopV1H\x06R\x08grantHop\x88\x01\x01\x12\x31\n\x04seal\x18\x1a \x01(\x0b\x32\x1d.host.authority.v1.WorkSealV1R\x04seal\x12[\n\x11operation_binding\x18\x1b \x01(\x0b\x32).host.authority.v1.WorkOperationBindingV1H\x07R\x10operationBinding\x88\x01\x01\x42\x14\n\x12_parent_session_idB\x0f\n\r_workspace_idB\r\n\x0b_project_idB\x17\n\x15_owner_principal_kindB\x11\n\x0f_owner_agent_idB\x12\n\x10_organization_idB\x0c\n\n_grant_hopB\x14\n\x12_operation_binding\"\xc0\x01\n\nWorkSealV1\x12\'\n\x0fprincipal_epoch\x18\x01 \x01(\x04R\x0eprincipalEpoch\x12\'\n\x0finstallation_id\x18\x02 \x01(\tR\x0einstallationId\x12\x33\n\x15installation_revision\x18\x03 \x01(\x04R\x14installationRevisionJ\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06R\x11\x62uild_incarnationR\x0cimage_digest\"u\n\x16WorkOperationBindingV1\x12\x1d\n\nbinding_id\x18\x01 \x01(\tR\tbindingId\x12\x1a\n\x08revision\x18\x02 \x01(\x04R\x08revision\x12 \n\x0bincarnation\x18\x03 \x01(\x04R\x0bincarnation\"\x9b\x01\n\x0bWorkScopeV1\x12#\n\rresource_kind\x18\x01 \x01(\tR\x0cresourceKind\x12\x18\n\x07\x61\x63tions\x18\x02 \x03(\tR\x07\x61\x63tions\x12!\n\x0cresource_ids\x18\x03 \x03(\tR\x0bresourceIds\x12*\n\x11resource_owner_id\x18\x04 \x01(\tR\x0fresourceOwnerId\"\x88\x03\n\x0bWorkActorV1\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12%\n\x0eprincipal_kind\x18\x02 \x01(\tR\rprincipalKind\x12#\n\rdelegation_id\x18\x03 \x01(\tR\x0c\x64\x65legationId\x12\x45\n\x0egranted_scopes\x18\x04 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\rgrantedScopes\x12\x1e\n\x08\x61gent_id\x18\x05 \x01(\tH\x00R\x07\x61gentId\x88\x01\x01\x12,\n\x0forganization_id\x18\x06 \x01(\tH\x01R\x0eorganizationId\x88\x01\x01\x12\'\n\x0fprincipal_epoch\x18\x07 \x01(\x04R\x0eprincipalEpochB\x0b\n\t_agent_idB\x12\n\x10_organization_idJ\x04\x08\x08\x10\tJ\x04\x08\t\x10\nR\x0cimage_digestR\x11\x62uild_incarnation\"\xf2\x01\n\x0eWorkGrantHopV1\x12\x19\n\x08grant_id\x18\x01 \x01(\tR\x07grantId\x12?\n\tapprovers\x18\x02 \x03(\x0b\x32!.host.authority.v1.WorkApproverV1R\tapprovers\x12\x43\n\rgranted_scope\x18\x03 \x01(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0cgrantedScope\x12\x18\n\x07subject\x18\x04 \x01(\tR\x07subject\x12%\n\x0erequest_digest\x18\x05 \x01(\tR\rrequestDigest\"Z\n\x0eWorkApproverV1\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12%\n\x0eprincipal_kind\x18\x02 \x01(\tR\rprincipalKind\"\xd9\x01\n\x12\x41pprovalRequiredV1\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12G\n\x0frequested_scope\x18\x02 \x01(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0erequestedScope\x12\x18\n\x07subject\x18\x03 \x01(\tR\x07subject\x12%\n\x0erequest_digest\x18\x04 \x01(\tR\rrequestDigest\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udienceB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -38,14 +38,14 @@ _globals['_WORKSEALV1']._serialized_end=1582 _globals['_WORKOPERATIONBINDINGV1']._serialized_start=1584 _globals['_WORKOPERATIONBINDINGV1']._serialized_end=1701 - _globals['_WORKSCOPEV1']._serialized_start=1703 - _globals['_WORKSCOPEV1']._serialized_end=1814 - _globals['_WORKACTORV1']._serialized_start=1817 - _globals['_WORKACTORV1']._serialized_end=2209 - _globals['_WORKGRANTHOPV1']._serialized_start=2212 - _globals['_WORKGRANTHOPV1']._serialized_end=2454 - _globals['_WORKAPPROVERV1']._serialized_start=2456 - _globals['_WORKAPPROVERV1']._serialized_end=2546 - _globals['_APPROVALREQUIREDV1']._serialized_start=2549 - _globals['_APPROVALREQUIREDV1']._serialized_end=2766 + _globals['_WORKSCOPEV1']._serialized_start=1704 + _globals['_WORKSCOPEV1']._serialized_end=1859 + _globals['_WORKACTORV1']._serialized_start=1862 + _globals['_WORKACTORV1']._serialized_end=2254 + _globals['_WORKGRANTHOPV1']._serialized_start=2257 + _globals['_WORKGRANTHOPV1']._serialized_end=2499 + _globals['_WORKAPPROVERV1']._serialized_start=2501 + _globals['_WORKAPPROVERV1']._serialized_end=2591 + _globals['_APPROVALREQUIREDV1']._serialized_start=2594 + _globals['_APPROVALREQUIREDV1']._serialized_end=2811 # @@protoc_insertion_point(module_scope) diff --git a/libraries/authority-client/python/authority_client/work_context.py b/libraries/authority-client/python/authority_client/work_context.py index 9fff90a5..f045f31d 100644 --- a/libraries/authority-client/python/authority_client/work_context.py +++ b/libraries/authority-client/python/authority_client/work_context.py @@ -109,6 +109,8 @@ class Scope: resource_kind: str actions: tuple[str, ...] resource_ids: tuple[str, ...] + # The principal whose resources the scope selects; "" selects no owner. + resource_owner_id: str = "" @dataclass(frozen=True) @@ -126,11 +128,21 @@ def allows(self, kind: str, action: str, resource_id: str = "") -> bool: scope containment; no admin implication or wildcard expansion happens here. A kind-wide grant includes any named resource. """ + return self.allows_owned(kind, action, resource_id, "") + + def allows_owned(self, kind: str, action: str, resource_id: str, owner_id: str) -> bool: + """``allows`` for a resource owned by owner_id, as the module's own record + names it: an owner-selected grant covers it only when it names that + owner (and, with ids, the id too); a grant naming no owner covers it + whatever its owner. ``allows`` names no owner, so an owner-selected + grant never satisfies it. An empty ID with an owner asks for every + resource that owner holds.""" if not kind or not action: return False return any( scope.resource_kind == kind and action in scope.actions + and (not scope.resource_owner_id or scope.resource_owner_id == owner_id) and (not scope.resource_ids or bool(resource_id) and resource_id in scope.resource_ids) for scope in self.scopes ) @@ -184,7 +196,7 @@ def authenticate( scopes = context.actor_chain[-1].granted_scopes if context.actor_chain else context.authority_scopes return Claims( audience=context.audience, - scopes=tuple(Scope(s.resource_kind, tuple(s.actions), tuple(s.resource_ids)) for s in scopes), + scopes=tuple(Scope(s.resource_kind, tuple(s.actions), tuple(s.resource_ids), s.resource_owner_id) for s in scopes), context=context, ) @@ -197,11 +209,13 @@ def _parsed(cls, payload: bytes): def scope_contained(child, parents) -> bool: """Core's ScopeContained: per action, a whole-kind row covers it, or the - child's ids all lie within the rows of that kind holding the action.""" + child's ids all lie within the rows of that kind holding the action. A row + naming an owner covers only a child naming the same owner.""" if not any(parent.resource_kind == child.resource_kind for parent in parents): return False for action in child.actions: - rows = [p for p in parents if p.resource_kind == child.resource_kind and action in p.actions] + rows = [p for p in parents if p.resource_kind == child.resource_kind and action in p.actions + and (not p.resource_owner_id or p.resource_owner_id == child.resource_owner_id)] if any(not p.resource_ids for p in rows): continue ids = {i for p in rows for i in p.resource_ids} diff --git a/libraries/authority-client/python/codefly_hop/__init__.py b/libraries/authority-client/python/codefly_hop/__init__.py index 6dc5109a..c72ec579 100644 --- a/libraries/authority-client/python/codefly_hop/__init__.py +++ b/libraries/authority-client/python/codefly_hop/__init__.py @@ -228,7 +228,8 @@ def _key_id(token: str) -> str: def _flatten(scopes) -> list[str]: out: list[str] = [] for scope in scopes: - if scope.resource_ids: + # A resource-limited or owner-selected scope is never a kind-wide one. + if scope.resource_ids or scope.resource_owner_id: continue for action in scope.actions: if f"{scope.resource_kind}:{action}" not in out: diff --git a/libraries/authority-client/python/tests/test_owner.py b/libraries/authority-client/python/tests/test_owner.py new file mode 100644 index 00000000..12f599e1 --- /dev/null +++ b/libraries/authority-client/python/tests/test_owner.py @@ -0,0 +1,53 @@ +"""The solution principal (conformance RunSolutionPrincipalTarget), read in Python. + +A module served in Python never reads an owner-selected scope as the whole +kind. Claims are built through ``authenticate`` from a signed context, so the +decode path keeps the owner: the owner-blind ``allows`` refuses an +owner-selected scope, ``allows_owned`` admits a resource only for its owner, +the hop's identity leaves owner-selected scopes out, and a chain whose hop +drops or changes the owner is refused. +""" +import pytest + +from authority_client._gen.host.authority.v1.work_context_pb2 import WorkContextV1 +from authority_client.work_context import Unauthenticated, authenticate +from codefly_hop import identity_of + +from test_work_context import PROOF, body, sign, stamped, trust_for + + +def claims_of(context): + return authenticate(stamped(sign(context)), PROOF, context.audience, trust_for(context)) + + +# The owner-selected rows (allows refusing, allows_owned admitting only the +# owner) are testdata/claims.json's "owner-selected" entry, run by +# test_work_context on the same rows as the Go and TypeScript readers. + + +def test_the_hop_identity_never_reads_an_owner_selected_scope_as_the_whole_kind(): + context = body("session") + del context.authority_scopes[:] + context.authority_scopes.add(resource_kind="record", actions=["read"], resource_owner_id="principal-u") + assert "record:read" not in identity_of(context).scopes + + +@pytest.mark.parametrize("last, ok", [ + ({"resource_ids": ["r1"], "resource_owner_id": "principal-u"}, True), + ({}, False), + ({"resource_owner_id": "principal-v"}, False), +], ids=["narrowed within the owner", "the owner dropped", "the owner changed"]) +def test_a_chain_that_drops_or_changes_the_owner_is_refused(last, ok): + context = body("delegated") + del context.authority_scopes[:] + context.authority_scopes.add(resource_kind="record", actions=["read"], resource_owner_id="principal-u") + for actor in context.actor_chain: + del actor.granted_scopes[:] + actor.granted_scopes.add(resource_kind="record", actions=["read"], resource_owner_id="principal-u") + del context.actor_chain[-1].granted_scopes[:] + context.actor_chain[-1].granted_scopes.add(resource_kind="record", actions=["read"], **last) + if ok: + claims_of(context) + else: + with pytest.raises(Unauthenticated): + claims_of(context) diff --git a/libraries/authority-client/python/tests/test_owner_target.py b/libraries/authority-client/python/tests/test_owner_target.py deleted file mode 100644 index d3c3e708..00000000 --- a/libraries/authority-client/python/tests/test_owner_target.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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/python/tests/test_work_context.py b/libraries/authority-client/python/tests/test_work_context.py index 38e3a16f..24f6fea5 100644 --- a/libraries/authority-client/python/tests/test_work_context.py +++ b/libraries/authority-client/python/tests/test_work_context.py @@ -57,11 +57,15 @@ def test_core_minted_contexts_authenticate_and_project_the_owner_scopes(fixture) claims = read(body(fixture["name"])) assert claims.audience == fixture["audience"] assert claims.scopes == tuple( - Scope(s["resource_kind"], tuple(s.get("actions", [])), tuple(s.get("resource_ids", []))) + Scope(s["resource_kind"], tuple(s.get("actions", [])), tuple(s.get("resource_ids", [])), s.get("resource_owner_id", "")) for s in fixture["scopes"] ) for probe in fixture["probes"]: - assert claims.allows(probe["kind"], probe["action"], probe["resource_id"]) == probe["allowed"] + if "owner" in probe: + got = claims.allows_owned(probe["kind"], probe["action"], probe["resource_id"], probe["owner"]) + else: + got = claims.allows(probe["kind"], probe["action"], probe["resource_id"]) + assert got == probe["allowed"], probe assert claims.context.tenant_id == body(fixture["name"]).tenant_id diff --git a/libraries/authority-client/testdata/claims.json b/libraries/authority-client/testdata/claims.json index 5ddc748b..54e4a528 100644 --- a/libraries/authority-client/testdata/claims.json +++ b/libraries/authority-client/testdata/claims.json @@ -298,5 +298,577 @@ "allowed": false } ] + }, + { + "name": "owner-selected", + "token": "Chdjb2RlZmx5LndvcmstY29udGV4dC92MRIHRWQyNTUxORoNY29uZm9ybWFuY2UtMSIeaHR0cHM6Ly9hdXRob3JpdHkuY29kZWZseS50ZXN0Khhjb2RlZmx5LnRlc3QvY29uZm9ybWFuY2UwoMX11gY4oMX11gZAsOH11gZKJDE1NzA2ZWY2LTlkYTgtNGJmMi04M2YzLTVlZWVjNGE5MWI5ZFAHWgppZGVtcG90ZW50YhJ0ZW5hbnQtY29uZm9ybWFuY2VqD3ByaW5jaXBhbC1vd25lcnIQdGFzay1jb25mb3JtYW5jZXokOTdhOGI2N2QtMzAwZC00Nzk0LTk3MjUtOGViZDAwMTQ0MmJiigEbCgZyZWNvcmQSBHJlYWQiC3ByaW5jaXBhbC11igEdCgRub3RlEgRyZWFkGgJuMSILcHJpbmNpcGFsLXWKAQ0KBXBsYWluEgRyZWFksgEFaHVtYW7KARhvcmdhbml6YXRpb24tY29uZm9ybWFuY2XSAR4IAhIYaW5zdGFsbGF0aW9uLWNvbmZvcm1hbmNlGAM.mnEA_zKLFKZy0GDHXdBHzLH-IohrrMHt6jfpTiKAR137EcQBZns2NeaeM3QI4CERRTEEyoEeP2x_USEd2QB5BA", + "audience": "codefly.test/conformance", + "scopes": [ + { + "resource_kind": "record", + "actions": [ + "read" + ], + "resource_owner_id": "principal-u" + }, + { + "resource_kind": "note", + "actions": [ + "read" + ], + "resource_ids": [ + "n1" + ], + "resource_owner_id": "principal-u" + }, + { + "resource_kind": "plain", + "actions": [ + "read" + ] + } + ], + "probes": [ + { + "kind": "record", + "action": "read", + "resource_id": "", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "record", + "action": "read", + "resource_id": "", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "", + "owner": "", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "other-resource", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "other-resource", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "record", + "action": "read", + "resource_id": "other-resource", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "other-resource", + "owner": "", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "record-conformance", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "record-conformance", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "record", + "action": "read", + "resource_id": "record-conformance", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "record", + "action": "read", + "resource_id": "record-conformance", + "owner": "", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "", + "owner": "", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "other-resource", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "record-conformance", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "record", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "other-resource", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "other-resource", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "other-resource", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "other-resource", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "n1", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "n1", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "note", + "action": "read", + "resource_id": "n1", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "n1", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "record-conformance", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "record-conformance", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "record-conformance", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "read", + "resource_id": "record-conformance", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "other-resource", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "n1", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "n1", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "n1", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "n1", + "owner": "", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "record-conformance", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "note", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "", + "allowed": false + }, + { + "kind": "plain", + "action": "read", + "resource_id": "", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "", + "owner": "principal-other", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "", + "owner": "", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "other-resource", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "other-resource", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "other-resource", + "owner": "principal-other", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "other-resource", + "owner": "", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "record-conformance", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "record-conformance", + "owner": "principal-u", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "record-conformance", + "owner": "principal-other", + "allowed": true + }, + { + "kind": "plain", + "action": "read", + "resource_id": "record-conformance", + "owner": "", + "allowed": true + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "", + "owner": "", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "other-resource", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "other-resource", + "owner": "", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "record-conformance", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "principal-u", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "principal-other", + "allowed": false + }, + { + "kind": "plain", + "action": "undeclared", + "resource_id": "record-conformance", + "owner": "", + "allowed": false + } + ] } ] diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/work_context_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/work_context_pb.ts index 85fa0b03..0b431d4d 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/work_context_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/work_context_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/work_context.proto. */ export const file_host_authority_v1_work_context: GenFile = /*@__PURE__*/ - fileDesc("CiRob3N0L2F1dGhvcml0eS92MS93b3JrX2NvbnRleHQucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxItAJCg1Xb3JrQ29udGV4dFYxEisKA3R5cBgBIAEoCUIeukgbchkKF2NvZGVmbHkud29yay1jb250ZXh0L3YxEiEKCWFsZ29yaXRobRgCIAEoCUIOukgLcgkKB0VkMjU1MTkSGgoGa2V5X2lkGAMgASgJQgq6SAdyBRABGIABEhoKBmlzc3VlchgEIAEoCUIKukgHcgUQARiABBIcCghhdWRpZW5jZRgFIAEoCUIKukgHcgUQARiABBIXCg9ub3RfYmVmb3JlX3VuaXgYBiABKAMSFgoOaXNzdWVkX2F0X3VuaXgYByABKAMSFwoPZXhwaXJlc19hdF91bml4GAggASgDEhkKBW5vbmNlGAkgASgJQgq6SAdyBRABGIAEEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCiABKAQSNAoNcmVwbGF5X3BvbGljeRgLIAEoCUIdukgachhSCmlkZW1wb3RlbnRSCnNpbmdsZS11c2USHQoJdGVuYW50X2lkGAwgASgJQgq6SAdyBRABGIAEEiYKEm93bmVyX3ByaW5jaXBhbF9pZBgNIAEoCUIKukgHcgUQARiABBIbCgd0YXNrX2lkGA4gASgJQgq6SAdyBRABGIAEEh4KCnNlc3Npb25faWQYDyABKAlCCrpIB3IFEAEYgAQSKgoRcGFyZW50X3Nlc3Npb25faWQYECABKAlCCrpIB3IFEAEYgARIAIgBARI4ChBhdXRob3JpdHlfc2NvcGVzGBEgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESMwoLYWN0b3JfY2hhaW4YEiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQWN0b3JWMRIcChRhdHRyaWJ1dGlvbl90ZWFtX2lkcxgTIAMoCRIlCgx3b3Jrc3BhY2VfaWQYFCABKAlCCrpIB3IFEAEYgARIAYgBARIjCgpwcm9qZWN0X2lkGBUgASgJQgq6SAdyBRABGIAESAKIAQESLQoUb3duZXJfcHJpbmNpcGFsX2tpbmQYFiABKAlCCrpIB3IFEAEYgAFIA4gBARInCg5vd25lcl9hZ2VudF9pZBgXIAEoCUIKukgHcgUQARiABEgEiAEBEigKD29yZ2FuaXphdGlvbl9pZBgZIAEoCUIKukgHcgUQARiABEgFiAEBEjkKCWdyYW50X2hvcBgYIAEoCzIhLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtHcmFudEhvcFYxSAaIAQESMwoEc2VhbBgaIAEoCzIdLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTZWFsVjFCBrpIA8gBARJJChFvcGVyYXRpb25fYmluZGluZxgbIAEoCzIpLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtPcGVyYXRpb25CaW5kaW5nVjFIB4gBAUIUChJfcGFyZW50X3Nlc3Npb25faWRCDwoNX3dvcmtzcGFjZV9pZEINCgtfcHJvamVjdF9pZEIXChVfb3duZXJfcHJpbmNpcGFsX2tpbmRCEQoPX293bmVyX2FnZW50X2lkQhIKEF9vcmdhbml6YXRpb25faWRCDAoKX2dyYW50X2hvcEIUChJfb3BlcmF0aW9uX2JpbmRpbmciqAEKCldvcmtTZWFsVjESIAoPcHJpbmNpcGFsX2Vwb2NoGAEgASgEQge6SAQyAigBEiMKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIKukgHcgUQARiABBImChVpbnN0YWxsYXRpb25fcmV2aXNpb24YAyABKARCB7pIBDICKAFKBAgEEAVKBAgFEAZSEWJ1aWxkX2luY2FybmF0aW9uUgxpbWFnZV9kaWdlc3QicQoWV29ya09wZXJhdGlvbkJpbmRpbmdWMRIeCgpiaW5kaW5nX2lkGAEgASgJQgq6SAdyBRABGIAEEhkKCHJldmlzaW9uGAIgASgEQge6SAQyAigBEhwKC2luY2FybmF0aW9uGAMgASgEQge6SAQyAigBIlcKC1dvcmtTY29wZVYxEiEKDXJlc291cmNlX2tpbmQYASABKAlCCrpIB3IFEAEYgAESDwoHYWN0aW9ucxgCIAMoCRIUCgxyZXNvdXJjZV9pZHMYAyADKAki7gIKC1dvcmtBY3RvclYxEiAKDHByaW5jaXBhbF9pZBgBIAEoCUIKukgHcgUQARiABBIiCg5wcmluY2lwYWxfa2luZBgCIAEoCUIKukgHcgUQARiAARIhCg1kZWxlZ2F0aW9uX2lkGAMgASgJQgq6SAdyBRABGIAEEjYKDmdyYW50ZWRfc2NvcGVzGAQgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESIQoIYWdlbnRfaWQYBSABKAlCCrpIB3IFEAEYgARIAIgBARIoCg9vcmdhbml6YXRpb25faWQYBiABKAlCCrpIB3IFEAEYgARIAYgBARIjCg9wcmluY2lwYWxfZXBvY2gYByABKARCCrpIB8gBATICKAFCCwoJX2FnZW50X2lkQhIKEF9vcmdhbml6YXRpb25faWRKBAgIEAlKBAgJEApSDGltYWdlX2RpZ2VzdFIRYnVpbGRfaW5jYXJuYXRpb24i8AEKDldvcmtHcmFudEhvcFYxEhwKCGdyYW50X2lkGAEgASgJQgq6SAdyBRABGIAEEkAKCWFwcHJvdmVycxgCIAMoCzIhLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtBcHByb3ZlclYxQgq6SAeSAQQIARBAEj0KDWdyYW50ZWRfc2NvcGUYAyABKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMUIGukgDyAEBEhsKB3N1YmplY3QYBCABKAlCCrpIB3IFEAEYgAQSIgoOcmVxdWVzdF9kaWdlc3QYBSABKAlCCrpIB3IFEAEYgAQiVgoOV29ya0FwcHJvdmVyVjESIAoMcHJpbmNpcGFsX2lkGAEgASgJQgq6SAdyBRABGIAEEiIKDnByaW5jaXBhbF9raW5kGAIgASgJQgq6SAdyBRABGIABItQBChJBcHByb3ZhbFJlcXVpcmVkVjESHgoKcmVxdWVzdF9pZBgBIAEoCUIKukgHcgUQARiABBI/Cg9yZXF1ZXN0ZWRfc2NvcGUYAiABKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMUIGukgDyAEBEhsKB3N1YmplY3QYAyABKAlCCrpIB3IFEAEYgAQSIgoOcmVxdWVzdF9kaWdlc3QYBCABKAlCCrpIB3IFEAEYgAQSHAoIYXVkaWVuY2UYBSABKAlCCrpIB3IFEAEYgARCL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_buf_validate_validate]); + fileDesc("CiRob3N0L2F1dGhvcml0eS92MS93b3JrX2NvbnRleHQucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxItAJCg1Xb3JrQ29udGV4dFYxEisKA3R5cBgBIAEoCUIeukgbchkKF2NvZGVmbHkud29yay1jb250ZXh0L3YxEiEKCWFsZ29yaXRobRgCIAEoCUIOukgLcgkKB0VkMjU1MTkSGgoGa2V5X2lkGAMgASgJQgq6SAdyBRABGIABEhoKBmlzc3VlchgEIAEoCUIKukgHcgUQARiABBIcCghhdWRpZW5jZRgFIAEoCUIKukgHcgUQARiABBIXCg9ub3RfYmVmb3JlX3VuaXgYBiABKAMSFgoOaXNzdWVkX2F0X3VuaXgYByABKAMSFwoPZXhwaXJlc19hdF91bml4GAggASgDEhkKBW5vbmNlGAkgASgJQgq6SAdyBRABGIAEEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCiABKAQSNAoNcmVwbGF5X3BvbGljeRgLIAEoCUIdukgachhSCmlkZW1wb3RlbnRSCnNpbmdsZS11c2USHQoJdGVuYW50X2lkGAwgASgJQgq6SAdyBRABGIAEEiYKEm93bmVyX3ByaW5jaXBhbF9pZBgNIAEoCUIKukgHcgUQARiABBIbCgd0YXNrX2lkGA4gASgJQgq6SAdyBRABGIAEEh4KCnNlc3Npb25faWQYDyABKAlCCrpIB3IFEAEYgAQSKgoRcGFyZW50X3Nlc3Npb25faWQYECABKAlCCrpIB3IFEAEYgARIAIgBARI4ChBhdXRob3JpdHlfc2NvcGVzGBEgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESMwoLYWN0b3JfY2hhaW4YEiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQWN0b3JWMRIcChRhdHRyaWJ1dGlvbl90ZWFtX2lkcxgTIAMoCRIlCgx3b3Jrc3BhY2VfaWQYFCABKAlCCrpIB3IFEAEYgARIAYgBARIjCgpwcm9qZWN0X2lkGBUgASgJQgq6SAdyBRABGIAESAKIAQESLQoUb3duZXJfcHJpbmNpcGFsX2tpbmQYFiABKAlCCrpIB3IFEAEYgAFIA4gBARInCg5vd25lcl9hZ2VudF9pZBgXIAEoCUIKukgHcgUQARiABEgEiAEBEigKD29yZ2FuaXphdGlvbl9pZBgZIAEoCUIKukgHcgUQARiABEgFiAEBEjkKCWdyYW50X2hvcBgYIAEoCzIhLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtHcmFudEhvcFYxSAaIAQESMwoEc2VhbBgaIAEoCzIdLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTZWFsVjFCBrpIA8gBARJJChFvcGVyYXRpb25fYmluZGluZxgbIAEoCzIpLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtPcGVyYXRpb25CaW5kaW5nVjFIB4gBAUIUChJfcGFyZW50X3Nlc3Npb25faWRCDwoNX3dvcmtzcGFjZV9pZEINCgtfcHJvamVjdF9pZEIXChVfb3duZXJfcHJpbmNpcGFsX2tpbmRCEQoPX293bmVyX2FnZW50X2lkQhIKEF9vcmdhbml6YXRpb25faWRCDAoKX2dyYW50X2hvcEIUChJfb3BlcmF0aW9uX2JpbmRpbmciqAEKCldvcmtTZWFsVjESIAoPcHJpbmNpcGFsX2Vwb2NoGAEgASgEQge6SAQyAigBEiMKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIKukgHcgUQARiABBImChVpbnN0YWxsYXRpb25fcmV2aXNpb24YAyABKARCB7pIBDICKAFKBAgEEAVKBAgFEAZSEWJ1aWxkX2luY2FybmF0aW9uUgxpbWFnZV9kaWdlc3QicQoWV29ya09wZXJhdGlvbkJpbmRpbmdWMRIeCgpiaW5kaW5nX2lkGAEgASgJQgq6SAdyBRABGIAEEhkKCHJldmlzaW9uGAIgASgEQge6SAQyAigBEhwKC2luY2FybmF0aW9uGAMgASgEQge6SAQyAigBIosBCgtXb3JrU2NvcGVWMRIhCg1yZXNvdXJjZV9raW5kGAEgASgJQgq6SAdyBRABGIABEg8KB2FjdGlvbnMYAiADKAkSFAoMcmVzb3VyY2VfaWRzGAMgAygJEjIKEXJlc291cmNlX293bmVyX2lkGAQgASgJQhe6SBRyEhiABDINXlteXHNceDAwKl0qJCLuAgoLV29ya0FjdG9yVjESIAoMcHJpbmNpcGFsX2lkGAEgASgJQgq6SAdyBRABGIAEEiIKDnByaW5jaXBhbF9raW5kGAIgASgJQgq6SAdyBRABGIABEiEKDWRlbGVnYXRpb25faWQYAyABKAlCCrpIB3IFEAEYgAQSNgoOZ3JhbnRlZF9zY29wZXMYBCADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIhCghhZ2VudF9pZBgFIAEoCUIKukgHcgUQARiABEgAiAEBEigKD29yZ2FuaXphdGlvbl9pZBgGIAEoCUIKukgHcgUQARiABEgBiAEBEiMKD3ByaW5jaXBhbF9lcG9jaBgHIAEoBEIKukgHyAEBMgIoAUILCglfYWdlbnRfaWRCEgoQX29yZ2FuaXphdGlvbl9pZEoECAgQCUoECAkQClIMaW1hZ2VfZGlnZXN0UhFidWlsZF9pbmNhcm5hdGlvbiLwAQoOV29ya0dyYW50SG9wVjESHAoIZ3JhbnRfaWQYASABKAlCCrpIB3IFEAEYgAQSQAoJYXBwcm92ZXJzGAIgAygLMiEuaG9zdC5hdXRob3JpdHkudjEuV29ya0FwcHJvdmVyVjFCCrpIB5IBBAgBEEASPQoNZ3JhbnRlZF9zY29wZRgDIAEoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxQga6SAPIAQESGwoHc3ViamVjdBgEIAEoCUIKukgHcgUQARiABBIiCg5yZXF1ZXN0X2RpZ2VzdBgFIAEoCUIKukgHcgUQARiABCJWCg5Xb3JrQXBwcm92ZXJWMRIgCgxwcmluY2lwYWxfaWQYASABKAlCCrpIB3IFEAEYgAQSIgoOcHJpbmNpcGFsX2tpbmQYAiABKAlCCrpIB3IFEAEYgAEi1AEKEkFwcHJvdmFsUmVxdWlyZWRWMRIeCgpyZXF1ZXN0X2lkGAEgASgJQgq6SAdyBRABGIAEEj8KD3JlcXVlc3RlZF9zY29wZRgCIAEoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxQga6SAPIAQESGwoHc3ViamVjdBgDIAEoCUIKukgHcgUQARiABBIiCg5yZXF1ZXN0X2RpZ2VzdBgEIAEoCUIKukgHcgUQARiABBIcCghhdWRpZW5jZRgFIAEoCUIKukgHcgUQARiABEIvWi1nYXRld2F5L3BrZy9nZW4vaG9zdC9hdXRob3JpdHkvdjE7YXV0aG9yaXR5djFiBnByb3RvMw", [file_buf_validate_validate]); /** * WorkContextV1 is the product-neutral identity and authority capability for @@ -416,6 +416,17 @@ export type WorkScopeV1 = Message<"host.authority.v1.WorkScopeV1"> & { * @generated from field: repeated string resource_ids = 3; */ resourceIds: string[]; + + /** + * resource_owner_id narrows the scope to the resources this principal owns + * (an installed solution's principal). Empty means the scope selects no + * owner. With resource_ids, a resource is covered only when both match. A + * reader that cannot compare a resource's owner must not treat an + * owner-selected scope as covering the whole kind. + * + * @generated from field: string resource_owner_id = 4; + */ + resourceOwnerId: string; }; /** diff --git a/libraries/authority-client/typescript/src/index.ts b/libraries/authority-client/typescript/src/index.ts index e05beb2d..ed07c9f1 100644 --- a/libraries/authority-client/typescript/src/index.ts +++ b/libraries/authority-client/typescript/src/index.ts @@ -1,4 +1,12 @@ // generated by codefly generate client; do not edit +export * as api_pb from "./packages/api.js"; +export * as buf_validate_pb from "./packages/buf.validate.js"; +export * as google_api_pb from "./packages/google.api.js"; +export * as host_artifact_v1_pb from "./packages/host.artifact.v1.js"; +export * as host_authority_v1_pb from "./packages/host.authority.v1.js"; +export * as host_composition_v1_pb from "./packages/host.composition.v1.js"; +export * as host_installation_v1_pb from "./packages/host.installation.v1.js"; +export * as host_session_v1_pb from "./packages/host.session.v1.js"; export * from "./gen/api_pb.js"; export * from "./gen/buf/validate/validate_pb.js"; export * from "./gen/google/api/annotations_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/api.ts b/libraries/authority-client/typescript/src/packages/api.ts new file mode 100644 index 00000000..a85aa41a --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/api.ts @@ -0,0 +1,2 @@ +// generated by codefly generate client from proto package api; do not edit +export * from "../gen/api_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/buf.validate.ts b/libraries/authority-client/typescript/src/packages/buf.validate.ts new file mode 100644 index 00000000..229a24c3 --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/buf.validate.ts @@ -0,0 +1,2 @@ +// generated by codefly generate client from proto package buf.validate; do not edit +export * from "../gen/buf/validate/validate_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/google.api.ts b/libraries/authority-client/typescript/src/packages/google.api.ts new file mode 100644 index 00000000..2de7c8d9 --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/google.api.ts @@ -0,0 +1,3 @@ +// generated by codefly generate client from proto package google.api; do not edit +export * from "../gen/google/api/annotations_pb.js"; +export * from "../gen/google/api/http_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/host.artifact.v1.ts b/libraries/authority-client/typescript/src/packages/host.artifact.v1.ts new file mode 100644 index 00000000..c0ed46f9 --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/host.artifact.v1.ts @@ -0,0 +1,2 @@ +// generated by codefly generate client from proto package host.artifact.v1; do not edit +export * from "../gen/host/artifact/v1/artifact_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/host.authority.v1.ts b/libraries/authority-client/typescript/src/packages/host.authority.v1.ts new file mode 100644 index 00000000..c14ac344 --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/host.authority.v1.ts @@ -0,0 +1,3 @@ +// generated by codefly generate client from proto package host.authority.v1; do not edit +export * from "../gen/host/authority/v1/authority_pb.js"; +export * from "../gen/host/authority/v1/work_context_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/host.composition.v1.ts b/libraries/authority-client/typescript/src/packages/host.composition.v1.ts new file mode 100644 index 00000000..afe079be --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/host.composition.v1.ts @@ -0,0 +1,2 @@ +// generated by codefly generate client from proto package host.composition.v1; do not edit +export * from "../gen/host/composition/v1/composition_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/host.installation.v1.ts b/libraries/authority-client/typescript/src/packages/host.installation.v1.ts new file mode 100644 index 00000000..3fdb251a --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/host.installation.v1.ts @@ -0,0 +1,2 @@ +// generated by codefly generate client from proto package host.installation.v1; do not edit +export * from "../gen/host/installation/v1/installation_pb.js"; diff --git a/libraries/authority-client/typescript/src/packages/host.session.v1.ts b/libraries/authority-client/typescript/src/packages/host.session.v1.ts new file mode 100644 index 00000000..590cffc2 --- /dev/null +++ b/libraries/authority-client/typescript/src/packages/host.session.v1.ts @@ -0,0 +1,2 @@ +// generated by codefly generate client from proto package host.session.v1; do not edit +export * from "../gen/host/session/v1/session_pb.js"; diff --git a/libraries/authority-client/typescript/tests/owner-target.test.mjs b/libraries/authority-client/typescript/tests/owner-target.test.mjs deleted file mode 100644 index f9ead743..00000000 --- a/libraries/authority-client/typescript/tests/owner-target.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// 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/authority-client/typescript/tests/owner.test.mjs b/libraries/authority-client/typescript/tests/owner.test.mjs new file mode 100644 index 00000000..e490b79d --- /dev/null +++ b/libraries/authority-client/typescript/tests/owner.test.mjs @@ -0,0 +1,48 @@ +// The solution principal (conformance RunSolutionPrincipalTarget), read in +// TypeScript: the owner-blind allows refuses an owner-selected scope, +// allowsOwned admits a resource only for its owner, the hop's identity leaves +// owner-selected scopes out, and a chain dropping or changing the owner is refused. +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, identityOf, trustFromKeys, Unauthenticated } from '@codefly-dev/authority-client/work-context'; + +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)); +const scope = fields => create(WorkScopeV1Schema, fields); + +// The owner-selected rows (allows refusing, allowsOwned admitting only the +// owner) are testdata/claims.json's "owner-selected" entry, run by +// work-context.test.mjs on the same rows as the Go and Python readers. + +test('the hop identity never reads an owner-selected scope as the whole kind', () => { + const context = body('session'); + context.authorityScopes = [scope({resourceKind:'record',actions:['read'],resourceOwnerId:'principal-u'})]; + assert.ok(!identityOf(context).scopes.includes('record:read')); +}); + +for (const [name, last, ok] of [ + ['narrowed within the owner', {resourceIds:['r1'],resourceOwnerId:'principal-u'}, true], + ['the owner dropped', {}, false], + ['the owner changed', {resourceOwnerId:'principal-v'}, false], +]) { + test(`a chain hop: ${name}`, () => { + const context = body('delegated'); + const owned = () => scope({resourceKind:'record',actions:['read'],resourceOwnerId:'principal-u'}); + context.authorityScopes = [owned()]; + for (const actor of context.actorChain) actor.grantedScopes = [owned()]; + context.actorChain.at(-1).grantedScopes = [scope({resourceKind:'record',actions:['read'],...last})]; + if (ok) read(context); + else assert.throws(() => read(context), e => e instanceof Unauthenticated); + }); +} diff --git a/libraries/authority-client/typescript/tests/work-context.test.mjs b/libraries/authority-client/typescript/tests/work-context.test.mjs index e8d17cb7..6224e486 100644 --- a/libraries/authority-client/typescript/tests/work-context.test.mjs +++ b/libraries/authority-client/typescript/tests/work-context.test.mjs @@ -22,8 +22,11 @@ for (const fixture of fixtures) { test(`Core-minted fixture authenticates: ${fixture.name}`, () => { const claims = read(body(fixture.name)); assert.equal(claims.audience,fixture.audience); - assert.deepEqual(claims.scopes, fixture.scopes.map(s=>({resourceKind:s.resource_kind,actions:s.actions??[],resourceIds:s.resource_ids??[]}))); - for (const probe of fixture.probes) assert.equal(claims.allows(probe.kind,probe.action,probe.resource_id),probe.allowed); + assert.deepEqual(claims.scopes, fixture.scopes.map(s=>({resourceKind:s.resource_kind,actions:s.actions??[],resourceIds:s.resource_ids??[],resourceOwnerId:s.resource_owner_id??''}))); + for (const probe of fixture.probes) { + const got = 'owner' in probe ? claims.allowsOwned(probe.kind,probe.action,probe.resource_id,probe.owner) : claims.allows(probe.kind,probe.action,probe.resource_id); + assert.equal(got,probe.allowed,JSON.stringify(probe)); + } }); } diff --git a/libraries/authority-client/typescript/work-context.ts b/libraries/authority-client/typescript/work-context.ts index 22a7dc33..8ba613c2 100644 --- a/libraries/authority-client/typescript/work-context.ts +++ b/libraries/authority-client/typescript/work-context.ts @@ -57,6 +57,8 @@ export interface Scope { readonly resourceKind: string; readonly actions: readonly string[]; readonly resourceIds: readonly string[]; + /** The principal whose resources the scope selects; "" selects no owner. */ + readonly resourceOwnerId: string; } export interface Claims { @@ -66,6 +68,9 @@ export interface Claims { readonly context: WorkContextV1; /** Empty resourceID asks for the entire kind. Actions and IDs are literal. */ allows(kind: string, action: string, resourceID?: string): boolean; + /** allows for a resource owned by ownerID: an owner-selected grant covers it + * only when it names that owner; allows names none, so it never does. */ + allowsOwned(kind: string, action: string, resourceID: string, ownerID: string): boolean; } /** Server-side request headers; repeated values must not be discarded. */ @@ -117,25 +122,33 @@ export function authenticateClaims(headers: StampHeaders, proof: string, audienc resourceKind: scope.resourceKind, actions: Object.freeze([...scope.actions]), resourceIds: Object.freeze([...scope.resourceIds]), + resourceOwnerId: scope.resourceOwnerId, }))); + const allowsOwned = (kind: string, action: string, resourceID: string, ownerID: string): boolean => { + if (!kind || !action) return false; + return scopes.some(scope => scope.resourceKind === kind && scope.actions.includes(action) && + (scope.resourceOwnerId === "" || scope.resourceOwnerId === ownerID) && + (scope.resourceIds.length === 0 || (resourceID !== "" && scope.resourceIds.includes(resourceID)))); + }; return Object.freeze({ audience: context.audience, scopes, context, allows(kind: string, action: string, resourceID = ""): boolean { - if (!kind || !action) return false; - return scopes.some(scope => scope.resourceKind === kind && scope.actions.includes(action) && - (scope.resourceIds.length === 0 || (resourceID !== "" && scope.resourceIds.includes(resourceID)))); + return allowsOwned(kind, action, resourceID, ""); }, + allowsOwned, }); } /** Core's ScopeContained: per action, a whole-kind row covers it, or the child's - * ids all lie within the rows of that kind holding the action. */ + * ids all lie within the rows of that kind holding the action. A row naming an + * owner covers only a child naming the same owner. */ export function scopeContained(child: WorkScopeV1, parents: readonly WorkScopeV1[]): boolean { if (!parents.some(p => p.resourceKind === child.resourceKind)) return false; for (const action of child.actions) { - const rows = parents.filter(p => p.resourceKind === child.resourceKind && p.actions.includes(action)); + const rows = parents.filter(p => p.resourceKind === child.resourceKind && p.actions.includes(action) && + (p.resourceOwnerId === "" || p.resourceOwnerId === child.resourceOwnerId)); if (rows.some(p => p.resourceIds.length === 0)) continue; const ids = new Set(rows.flatMap(p => p.resourceIds)); if (child.resourceIds.length === 0 || child.resourceIds.some(id => !ids.has(id))) return false; @@ -234,7 +247,7 @@ export interface Identity { function flatten(scopes: readonly WorkScopeV1[]): string[] { const out: string[] = []; for (const scope of scopes) { - if (scope.resourceIds.length > 0) continue; + if (scope.resourceIds.length > 0 || scope.resourceOwnerId !== "") continue; for (const action of scope.actions) if (!out.includes(`${scope.resourceKind}:${action}`)) out.push(`${scope.resourceKind}:${action}`); } return out; diff --git a/libraries/authority-core/go/decide/decide.go b/libraries/authority-core/go/decide/decide.go new file mode 100644 index 00000000..5dc8d8ee --- /dev/null +++ b/libraries/authority-core/go/decide/decide.go @@ -0,0 +1,273 @@ +// Package decide is the authority's pure decisions: each takes its inputs +// explicitly and answers a result or a named refusal (domain.Refusal). It +// reads no store and signs nothing, so both hosts decide identically. +package decide + +import ( + "slices" + "strings" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + resources "github.com/codefly-dev/authority/libraries/resources/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// Scopes is a scope list. +type Scopes = []*basev0.WorkScopeV1 + +// IsAdministrator: an organization's administrators hold "admin" or "owner". +func IsAdministrator(p domain.Person) bool { + return slices.Contains(p.Roles, "admin") || slices.Contains(p.Roles, "owner") +} + +// OrgScopes is what a person's organization roles grant at audience: the +// union of their scopes (AnyKind spanning every served kind) within what the +// audience serves. Resource matchers are ListResources' (ResourceGrants). +func OrgScopes(roles []domain.OrgRole, audience domain.Module) Scopes { + var held Scopes + for _, role := range roles { + for _, sc := range role.Scopes { + if sc.Kind == scope.AnyKind { + for _, kind := range audience.ResourceKinds { + held = append(held, &basev0.WorkScopeV1{ResourceKind: kind.GetResourceKind(), Actions: slices.Clone(sc.Actions)}) + } + continue + } + held = append(held, &basev0.WorkScopeV1{ResourceKind: sc.Kind, Actions: slices.Clone(sc.Actions)}) + } + } + return scope.Intersect(scope.Normalize(held), audience.ResourceKinds) +} + +// ResourceGrants are the resource matchers of every organization-role scope +// holding action on kind, or every resource for a scope that names none. +func ResourceGrants(roles []domain.OrgRole, kind, action string) []resources.Grant { + var grants []resources.Grant + for _, role := range roles { + for _, sc := range role.Scopes { + if (sc.Kind != kind && sc.Kind != scope.AnyKind) || (!slices.Contains(sc.Actions, action) && !slices.Contains(sc.Actions, scope.AnyAction)) { + continue + } + if len(sc.Resources) == 0 { + return []resources.Grant{{All: true}} + } + grants = append(grants, sc.Resources...) + } + } + return grants +} + +// HeldRoles are the roles a person holds on one installation: granted on it +// to them, or to a team they belong to. Grants on any other installation +// count for nothing here, however the caller gathered them. +func HeldRoles(p domain.Person, inst domain.Installation, grants []domain.RoleGrant) []string { + var roles []string + for _, g := range grants { + if g.InstallationID != inst.ID { + continue + } + if (g.PersonID != "" && g.PersonID == p.ID) || (g.TeamID != "" && slices.Contains(p.Teams, g.TeamID)) { + roles = append(roles, g.Role) + } + } + slices.Sort(roles) + return slices.Compact(roles) +} + +// RoleScopes is what roles grant on a solution's installation: the roles' +// scopes, each selecting only the resources the installation's principal +// owns. An installation with no principal grants nothing: its scopes would +// select every owner's resources. +func RoleScopes(solution domain.Module, inst domain.Installation, roles []string) (Scopes, error) { + var held Scopes + for _, role := range roles { + held = append(held, solution.Roles[role]...) + } + if len(held) == 0 { + return nil, nil + } + if inst.PrincipalID == "" { + return nil, domain.Refuse("installation %s has no principal, so its roles grant nothing", inst.ID) + } + return scope.Owned(held, inst.PrincipalID) +} + +// Issue is asked within held: held when nothing is asked; refused, naming +// why, when the ask names an action the owner does not hold ("beyond") or +// resources or an owner wider than it holds them ("widens"). +func Issue(asked, held Scopes) (Scopes, error) { + if len(asked) == 0 { + if len(held) == 0 { + return nil, domain.Refuse("the owner holds nothing at this audience") + } + for _, sc := range held { + if slices.Contains(sc.GetActions(), scope.AnyAction) { + return nil, domain.Refuse("the audience declares %s without its actions; ask for the scopes you need", sc.GetResourceKind()) + } + } + return held, nil + } + if scope.Attenuates(asked, held) { + return scope.Normalize(asked), nil + } + if scope.Attenuates(scope.Unowned(asked), scope.Unowned(held)) { + return nil, domain.Refuse("the ask widens what the owner holds: beyond the resources or the owner it holds them on") + } + return nil, domain.Refuse("asked beyond what the owner holds") +} + +// Child is the refusal of an ask beyond a child's ceiling. Through a solution +// it says the capability is confined to the principal's data, the same words +// whatever the person's own grants would cover, so the module asking learns +// nothing of them. +func Child(binding string, throughSolution bool) error { + if throughSolution { + return domain.Refuse("a child widens what its owner holds through binding %q: a capability issued through a solution is confined to its principal's data, and the person's own grants never flow into it", binding) + } + return domain.Refuse("a child widens what its owner holds through binding %q", binding) +} + +// OwnerAware refuses owner-selected scopes to an audience that does not +// declare owner-aware checking: its module could read them as the whole kind. +func OwnerAware(audience domain.Module, scopes Scopes) error { + if scope.HasOwner(scopes) && !audience.OwnerAware { + return domain.Refuse("%s does not declare owner-aware checking, so it receives no owner-selected scope", audience.Principal) + } + return nil +} + +// ScopesFor is what a binding may do for one operation, or def's when the +// request names none. +func ScopesFor(b domain.Binding, operation, def string) (Scopes, error) { + if operation == "" { + operation = def + } + switch operation { + case "invoke": + return b.InvokeScopes, nil + case "headless": + return b.HeadlessScopes, nil + case "lookup": + return b.LookupScopes, nil + } + return nil, domain.Refuse("operation %q is none of invoke, headless and lookup", operation) +} + +// RoleChange is one GrantRole, RevokeRole or ListRoleGrants request with the +// state it is decided against. +type RoleChange struct { + // RequestOrganization is the organization the request names. + RequestOrganization string + Installation domain.Installation + Solution domain.Module + // Caller is the authenticated person; CallerRoles what they hold on the installation. + Caller domain.Person + CallerRoles []string + // List asks only who may read the grants; Revoke ends a grant, which + // stays revocable after its person left or its team is gone. + List, Revoke bool + Role, PersonID, TeamID string + // Subject is the person granted, when PersonID names one the host knows. + Subject *domain.Person + // TeamOfOrganization: a person of the installation's organization belongs to TeamID. + TeamOfOrganization bool +} + +// ChangeRole decides a role change, in order: the organization the request +// names must be the installation's, before the caller's authority is read; +// then the caller must administer that organization or hold the installer +// role on that installation; then the installation must be active, the role +// declared, exactly one subject named, and that subject of the organization. +func ChangeRole(r RoleChange) error { + inst := r.Installation + if r.RequestOrganization != inst.Organization { + return ForeignInstallation(inst.ID, r.RequestOrganization) + } + if r.Caller.Organization != inst.Organization || (!IsAdministrator(r.Caller) && !slices.Contains(r.CallerRoles, domain.InstallerRole)) { + if r.List { + return domain.Refuse("%s may not list the role grants of %s", r.Caller.ID, inst.ID) + } + return domain.Refuse("%s may not grant roles on %s: an administrator of %s or its installer grants", r.Caller.ID, inst.ID, inst.Organization) + } + if inst.Status != domain.Active { + return domain.NotNow("installation %s is %s: %s is not installed", inst.ID, inst.Status, inst.Target) + } + if r.List { + return nil + } + if _, declared := r.Solution.Roles[r.Role]; !declared { + return domain.Missing("%s declares no role %q", inst.Target, r.Role) + } + if (r.PersonID == "") == (r.TeamID == "") { + return domain.Malformed("a role grant names exactly one person or team") + } + if r.Revoke { + return nil + } + if r.PersonID != "" && (r.Subject == nil || r.Subject.Organization != inst.Organization) { + return domain.Refuse("%q is not a member of %s", r.PersonID, inst.Organization) + } + if r.TeamID != "" && !r.TeamOfOrganization { + return domain.Refuse("%q is not a team of %s", r.TeamID, inst.Organization) + } + return nil +} + +// ForeignInstallation is the one answer for an installation the request's +// organization does not have, whether another organization has it or nobody +// does: a person learns nothing of another organization's installations. +func ForeignInstallation(id, organization string) error { + return domain.Refuse("installation %s belongs to another organization than %q", id, organization) +} + +// Transfer is one AuthorizeTransfer with what it is decided against. +type Transfer struct { + // OwnerKind and Person are the presented capability's owner. + OwnerKind string + Person domain.Person + // Through are the solution principals the capability was issued through. + Through []string + // From is the store-attested current owner; FromPrincipal: it is a + // solution principal; FromPerson: the person it names, if any. + From string + FromPrincipal bool + FromPerson *domain.Person + To string + // Administers: the person's own organization grants hold admin on the resource. + Administers bool + // Writes: the person's current roles on To's installation hold write on the kind. + Writes bool +} + +// AuthorizeTransfer decides whether an existing resource joins a solution +// principal: a person's act, through that principal's installation, from the +// organization or a person of it, administered on that resource through the +// person's own grants, by a holder of write on the principal's data. +func AuthorizeTransfer(t Transfer) error { + switch { + case t.OwnerKind != "human": + return domain.Refuse("a transfer is a person's act; this capability is owned by a %s", t.OwnerKind) + case !slices.Contains(t.Through, t.To): + return domain.Refuse("the capability was not issued through the installation of %s", t.To) + case t.FromPrincipal: + return domain.Refuse("%s is owned by a solution principal; a resource moves between principals by no transfer", t.From) + case t.From != t.Person.Organization && (t.FromPerson == nil || t.FromPerson.Organization != t.Person.Organization): + return domain.Refuse("%q is not a person or the organization of %s", t.From, t.Person.Organization) + case !t.Administers: + return domain.Refuse("%s holds no admin on the resource from their own organization grants", t.Person.ID) + case !t.Writes: + return domain.Refuse("%s holds no write on %s's data through their roles", t.Person.ID, t.To) + } + return nil +} + +// ValidResourceID holds a resource id to core's scope-value rule (non-empty, +// no surrounding whitespace, no NUL, CR, LF or tab), refuses any id containing +// "*", and bounds it to 128 bytes; a malformed id is Invalid. Ids are opaque and compared byte for byte. +func ValidResourceID(id string) error { + if id == "" || strings.TrimSpace(id) != id || strings.ContainsAny(id, "\x00\r\n\t*") || len(id) > 128 { + return domain.Malformed("resource id %q is not a usable id", id) + } + return nil +} diff --git a/libraries/authority-core/go/decide/decide_test.go b/libraries/authority-core/go/decide/decide_test.go new file mode 100644 index 00000000..34668925 --- /dev/null +++ b/libraries/authority-core/go/decide/decide_test.go @@ -0,0 +1,263 @@ +package decide_test + +import ( + "errors" + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/decide" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + resources "github.com/codefly-dev/authority/libraries/resources/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +func sc(kind string, actions ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions} +} + +func owned(kind, owner string, actions []string, ids ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions, ResourceIds: ids, ResourceOwnerId: owner} +} + +func refused(t *testing.T, err error, code domain.Code, reason string) { + t.Helper() + got, ok := domain.CodeOf(err) + if !ok || got != code || !strings.Contains(err.Error(), reason) { + t.Fatalf("want code %v naming %q, got %v", code, reason, err) + } +} + +var solution = domain.Module{Name: "a", Principal: "a", Kind: domain.KindSolution, Generation: 1, + ResourceKinds: []*basev0.WorkScopeV1{sc("a.note", "read", "write")}, + Bindings: map[string]domain.Binding{"store": {ID: "store", Audience: "store", InvokeScopes: []*basev0.WorkScopeV1{sc("doc", "read", "write")}}}, + Roles: map[string][]*basev0.WorkScopeV1{ + domain.InstallerRole: {sc("a.note", "read", "write"), sc("doc", "read", "write")}, + "viewer": {sc("a.note", "read"), sc("doc", "read")}, + }} + +func TestRolesAreGrantsOnThePrincipalsData(t *testing.T) { + inst := domain.Installation{ID: "i", Organization: "acme", Target: "a", Status: domain.Active, PrincipalID: "u"} + maya := domain.Person{ID: "maya", Organization: "acme", Teams: []string{"finance"}} + grants := []domain.RoleGrant{{InstallationID: "i", Role: "viewer", TeamID: "finance"}, {InstallationID: "i", Role: domain.InstallerRole, PersonID: "alex"}, + {InstallationID: "elsewhere", Role: domain.InstallerRole, PersonID: "maya"}} + roles := decide.HeldRoles(maya, inst, grants) + if len(roles) != 1 || roles[0] != "viewer" { + t.Fatalf("held %v: a team grant is its members' role, a person's grant theirs alone, another installation's grant nothing here", roles) + } + scopes, err := decide.RoleScopes(solution, inst, roles) + if err != nil || len(scopes) == 0 { + t.Fatalf("role scopes %v, %v", scopes, err) + } + for _, s := range scopes { + if s.GetResourceOwnerId() != "u" { + t.Fatalf("a role scope %v is not owned by the principal", s) + } + } + if got, err := decide.RoleScopes(solution, inst, nil); got != nil || err != nil { + t.Fatal("no role grants something") + } +} + +// An installation with no principal (a zero value, a row from before the +// principal existed) grants nothing: an empty owner selects every owner. +func TestAnInstallationWithoutAPrincipalGrantsNothing(t *testing.T) { + got, err := decide.RoleScopes(solution, domain.Installation{ID: "i"}, []string{"viewer"}) + refused(t, err, domain.Denied, "no principal") + if got != nil { + t.Fatalf("an installation with no principal granted %v", got) + } +} + +func TestIssueNamesWhyAnAskIsRefused(t *testing.T) { + held := []*basev0.WorkScopeV1{owned("doc", "u", []string{"read"})} + if got, err := decide.Issue(nil, held); err != nil || len(got) != 1 { + t.Fatalf("an empty ask is everything held: %v %v", got, err) + } + _, err := decide.Issue([]*basev0.WorkScopeV1{owned("doc", "u", []string{"admin"})}, held) + refused(t, err, domain.Denied, "beyond") + for _, ask := range []*basev0.WorkScopeV1{sc("doc", "read"), owned("doc", "v", []string{"read"})} { + _, err := decide.Issue([]*basev0.WorkScopeV1{ask}, held) + refused(t, err, domain.Denied, "widens") + } + _, err = decide.Issue(nil, nil) + refused(t, err, domain.Denied, "holds nothing") +} + +// Through a solution the refusal says the capability is confined, in the +// same words whatever the person's own grants hold, so the module asking +// learns nothing of them; elsewhere it says the child widens. +func TestAChildRefusalNamesConfinementAndRevealsNoGrant(t *testing.T) { + through := decide.Child("store", true) + refused(t, through, domain.Denied, "confined") + refused(t, through, domain.Denied, "widens") + refused(t, decide.Child("store", false), domain.Denied, "widens") + if strings.Contains(decide.Child("store", false).Error(), "confined") { + t.Fatal("a child through no solution is called confined") + } +} + +func TestOwnerSelectedScopesNeedAnOwnerAwareAudience(t *testing.T) { + scopes := []*basev0.WorkScopeV1{owned("doc", "u", []string{"read"})} + refused(t, decide.OwnerAware(domain.Module{Principal: "legacy"}, scopes), domain.Denied, "owner-aware") + if err := decide.OwnerAware(domain.Module{Principal: "store", OwnerAware: true}, scopes); err != nil { + t.Fatal(err) + } + if err := decide.OwnerAware(domain.Module{Principal: "legacy"}, []*basev0.WorkScopeV1{sc("doc", "read")}); err != nil { + t.Fatal("a plain scope needs no owner-aware audience") + } +} + +func TestWhoMayChangeARoleInOrder(t *testing.T) { + inst := domain.Installation{ID: "i", Organization: "acme", Target: "a", Status: domain.Active} + admin := domain.Person{ID: "alex", Organization: "acme", Roles: []string{"admin"}} + member := domain.Person{ID: "dev", Organization: "acme"} + omar := domain.Person{ID: "omar", Organization: "globex"} + base := func() decide.RoleChange { + return decide.RoleChange{RequestOrganization: "acme", Installation: inst, Solution: solution, Caller: admin, Role: "viewer", PersonID: "dev", Subject: &member} + } + if err := decide.ChangeRole(base()); err != nil { + t.Fatal(err) + } + installer := base() + installer.Caller, installer.CallerRoles = member, []string{domain.InstallerRole} + if err := decide.ChangeRole(installer); err != nil { + t.Fatalf("the installer grants: %v", err) + } + for name, c := range map[string]struct { + mutate func(*decide.RoleChange) + code domain.Code + reason string + }{ + "another organization, even for a non-admin": {func(r *decide.RoleChange) { r.RequestOrganization = "globex"; r.Caller = member }, domain.Denied, "organization"}, + "a member": {func(r *decide.RoleChange) { r.Caller = member }, domain.Denied, "may not grant"}, + "another organization's admin": {func(r *decide.RoleChange) { + r.Caller = domain.Person{ID: "zoe", Organization: "globex", Roles: []string{"admin"}} + }, domain.Denied, "may not grant"}, + "a disabled installation": {func(r *decide.RoleChange) { r.Installation.Status = domain.Revoked }, domain.Precondition, "not installed"}, + "an undeclared role": {func(r *decide.RoleChange) { r.Role = "owner" }, domain.NotFound, "declares no role"}, + "both subjects": {func(r *decide.RoleChange) { r.TeamID = "t" }, domain.Invalid, "exactly one"}, + "no subject": {func(r *decide.RoleChange) { r.PersonID = "" }, domain.Invalid, "exactly one"}, + "a person of another organization": {func(r *decide.RoleChange) { r.PersonID, r.Subject = "omar", &omar }, domain.Denied, "not a member"}, + "a team of another organization": {func(r *decide.RoleChange) { r.PersonID, r.TeamID = "", "ops" }, domain.Denied, "not a team of"}, + "a member listing": {func(r *decide.RoleChange) { r.Caller, r.List = member, true }, domain.Denied, "may not list"}, + "a member revoking": {func(r *decide.RoleChange) { r.Caller, r.Revoke = member, true }, domain.Denied, "may not grant"}, + } { + t.Run(name, func(t *testing.T) { + r := base() + c.mutate(&r) + refused(t, decide.ChangeRole(r), c.code, c.reason) + }) + } + // A grant stays revocable after its person left or its team is gone. + for name, mutate := range map[string]func(*decide.RoleChange){ + "a person who left": func(r *decide.RoleChange) { r.Subject = nil }, + "a person now elsewhere": func(r *decide.RoleChange) { r.Subject = &omar }, + "a team no member carries": func(r *decide.RoleChange) { r.PersonID, r.TeamID = "", "gone" }, + } { + t.Run("revoking "+name, func(t *testing.T) { + r := base() + r.Revoke = true + mutate(&r) + if err := decide.ChangeRole(r); err != nil { + t.Fatalf("the grant cannot be revoked: %v", err) + } + }) + } + // Another organization's installation and none at all answer alike. + other := base() + other.RequestOrganization = "globex" + if got, absent := decide.ChangeRole(other).Error(), decide.ForeignInstallation(inst.ID, "globex").Error(); got != absent { + t.Fatalf("a foreign installation answers %q, an absent one %q", got, absent) + } +} + +func TestATransferIsDecidedInOrder(t *testing.T) { + ada := domain.Person{ID: "ada", Organization: "acme"} + omar := domain.Person{ID: "omar", Organization: "globex"} + base := func() decide.Transfer { + return decide.Transfer{OwnerKind: "human", Person: ada, Through: []string{"u"}, From: "acme", To: "u", Administers: true, Writes: true} + } + if err := decide.AuthorizeTransfer(base()); err != nil { + t.Fatal(err) + } + fromPerson := base() + fromPerson.From, fromPerson.FromPerson = "ada", &ada + if err := decide.AuthorizeTransfer(fromPerson); err != nil { + t.Fatalf("from a person of the organization: %v", err) + } + for name, c := range map[string]struct { + mutate func(*decide.Transfer) + reason string + }{ + "the agent": {func(x *decide.Transfer) { x.OwnerKind = "agent" }, "a person's act"}, + "to a principal not through": {func(x *decide.Transfer) { x.To = "v" }, "not issued through"}, + "from a solution principal": {func(x *decide.Transfer) { x.From, x.FromPrincipal = "v", true }, "solution principal"}, + "from another organization": {func(x *decide.Transfer) { x.From = "globex" }, "not a person or the organization"}, + "from another organization's person": {func(x *decide.Transfer) { x.From, x.FromPerson = "omar", &omar }, "not a person or the organization"}, + "from nobody": {func(x *decide.Transfer) { x.From = "" }, "not a person or the organization"}, + "without admin on the resource": {func(x *decide.Transfer) { x.Administers = false }, "own organization grants"}, + "without write on the principal": {func(x *decide.Transfer) { x.Writes = false }, "write on"}, + } { + t.Run(name, func(t *testing.T) { + x := base() + c.mutate(&x) + refused(t, decide.AuthorizeTransfer(x), domain.Denied, c.reason) + }) + } + for _, id := range []string{"", " x", "a*b", "x\n", strings.Repeat("x", 129)} { + refused(t, decide.ValidResourceID(id), domain.Invalid, "not a usable id") + } +} + +func TestOrganizationGrantsAndModules(t *testing.T) { + audience := domain.Module{ResourceKinds: []*basev0.WorkScopeV1{sc("doc", "read", "admin")}} + roles := []domain.OrgRole{{Name: "r", Scopes: []domain.RoleScope{{Kind: "*", Actions: []string{"read"}}, {Kind: "doc", Actions: []string{"admin"}, Resources: []resources.Grant{{ID: "x"}}}}}} + if got := decide.OrgScopes(roles, audience); len(got) != 1 || len(got[0].GetActions()) != 2 { + t.Fatalf("org scopes %v", got) + } + if got := decide.ResourceGrants(roles, "doc", "admin"); len(got) != 1 || got[0].ID != "x" { + t.Fatalf("matchers %v", got) + } + if err := decide.ValidateModule(solution); err != nil { + t.Fatal(err) + } + bad := solution + bad.Roles = map[string][]*basev0.WorkScopeV1{domain.InstallerRole: {owned("doc", "u", []string{"read"})}} + refused(t, decide.ValidateModule(bad), domain.Denied, "selects an owner") + module := domain.Module{Name: "m", Principal: "m", Kind: domain.KindModule, Generation: 1, Roles: solution.Roles} + refused(t, decide.ValidateModule(module), domain.Denied, "declares roles") + noInstaller := solution + noInstaller.Roles = map[string][]*basev0.WorkScopeV1{"viewer": solution.Roles["viewer"]} + if err := decide.ValidateModule(noInstaller); !errors.Is(err, domain.ErrNoInstallerRole) { + t.Fatalf("a solution with no installer role: %v", err) + } + for name, methods := range map[string]map[string]domain.MethodPolicy{ + "a method naming resource ids": {"/doc.Store/Read": {Requires: []*basev0.WorkScopeV1{{ResourceKind: "doc", Actions: []string{"read"}, ResourceIds: []string{"x"}}}}}, + "a method naming an owner": {"/doc.Store/Read": {Requires: []*basev0.WorkScopeV1{owned("doc", "u", []string{"read"})}}}, + "a method requiring and not": {"/doc.Store/Read": {Requires: []*basev0.WorkScopeV1{sc("doc", "read")}, AnyCheckedCaller: true}}, + } { + t.Run(name, func(t *testing.T) { + m := domain.Module{Name: "m", Principal: "m", Kind: domain.KindModule, Generation: 1, Methods: methods} + refused(t, decide.ValidateModule(m), domain.Denied, "m:") + }) + } + served := domain.Module{Name: "m", Principal: "m", Kind: domain.KindModule, Generation: 1, + Methods: map[string]domain.MethodPolicy{"/doc.Store/Read": {Requires: []*basev0.WorkScopeV1{sc("doc", "read")}}, "/doc.Store/Ping": {AnyCheckedCaller: true}}} + if err := decide.ValidateModule(served); err != nil { + t.Fatalf("a valid method policy: %v", err) + } +} + +// Withdrawing owner-aware checking is a narrowing: owner-selected +// capabilities already issued to the module end with it. +func TestWithdrawingOwnerAwareIsANarrowing(t *testing.T) { + aware := domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 1, OwnerAware: true} + blind := aware + blind.OwnerAware, blind.Generation = false, 2 + if !decide.Narrowed(aware, blind) { + t.Fatal("withdrawing owner-aware checking is not a narrowing") + } + if decide.Narrowed(blind, aware) { + t.Fatal("declaring owner-aware checking is a narrowing") + } +} diff --git a/libraries/authority-core/go/decide/manifest.go b/libraries/authority-core/go/decide/manifest.go new file mode 100644 index 00000000..2cc1cb0a --- /dev/null +++ b/libraries/authority-core/go/decide/manifest.go @@ -0,0 +1,100 @@ +package decide + +import ( + "fmt" + "maps" + "slices" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + delivery "github.com/codefly-dev/authority/libraries/delivery/go" +) + +// ValidateModule holds a delivered module to its contract: a principal, a +// generation, bindings whose lookup and headless scopes stay within invoke; +// a module declares no roles; a solution declares installer, and every role +// stays within what it serves or consumes through invoke/lookup bindings. +func ValidateModule(m domain.Module) error { + if m.Name == "" || m.Principal == "" || m.Generation == 0 { + return domain.Refuse("a manifest names its module, its principal and a generation") + } + for _, method := range slices.Sorted(maps.Keys(m.Methods)) { + policy := m.Methods[method] + requires := map[string][]string{} + for _, sc := range policy.Requires { + if len(sc.GetResourceIds()) > 0 || sc.GetResourceOwnerId() != "" { + return domain.Refuse("%s: %s: method requirements name whole resource kinds; resource ids and owners are not supported", m.Name, method) + } + requires[sc.GetResourceKind()] = append(requires[sc.GetResourceKind()], sc.GetActions()...) + } + if err := delivery.ValidateMethodPolicy(method, policy.AnyCheckedCaller, requires, false); err != nil { + return domain.Refuse("%s: %v", m.Name, err) + } + } + for _, id := range slices.Sorted(maps.Keys(m.Bindings)) { + b := m.Bindings[id] + lookupCeiling := b.InvokeScopes + if len(lookupCeiling) == 0 { + lookupCeiling = b.HeadlessScopes + } + if len(b.LookupScopes) > 0 && !scope.Attenuates(b.LookupScopes, lookupCeiling) { + return domain.Refuse("binding %s: lookup scopes exceed what the binding may do", id) + } + if len(b.InvokeScopes) > 0 && !scope.Attenuates(b.HeadlessScopes, b.InvokeScopes) { + return domain.Refuse("binding %s: headless scopes exceed invoke scopes", id) + } + } + if m.Kind != domain.KindSolution { + if len(m.Roles) > 0 { + return domain.Refuse("module %q declares roles; a module's access is the organization's roles", m.Name) + } + return nil + } + if _, ok := m.Roles[domain.InstallerRole]; !ok { + return &domain.Refusal{Code: domain.Denied, Cause: domain.ErrNoInstallerRole, + Reason: fmt.Sprintf("%s declares %d role(s) and none is %q: a solution declares an installer role", m.Name, len(m.Roles), domain.InstallerRole)} + } + grantable := slices.Clone(m.ResourceKinds) + for _, b := range m.Bindings { + grantable = append(grantable, b.InvokeScopes...) + grantable = append(grantable, b.LookupScopes...) + } + for _, name := range slices.Sorted(maps.Keys(m.Roles)) { + scopes := m.Roles[name] + if len(scopes) == 0 { + return domain.Refuse("role %q of %s grants nothing", name, m.Name) + } + if scope.HasOwner(scopes) { + return domain.Refuse("role %q of %s selects an owner; a role's owner is its installation's principal", name, m.Name) + } + if !scope.Attenuates(scopes, grantable) { + return domain.Refuse("role %q of %s grants beyond what %s serves or explicitly consumes through invoke/lookup bindings", name, m.Name, m.Name) + } + } + return nil +} + +// Narrowed reports whether next narrows what previous granted: a binding +// removed, readdressed or cut, owner-aware checking withdrawn, or any of a +// solution's roles cut. A narrowing invalidates capabilities already issued. +func Narrowed(previous, next domain.Module) bool { + if previous.OwnerAware && !next.OwnerAware { + return true + } + for id, old := range previous.Bindings { + b, ok := next.Bindings[id] + if !ok || old.Audience != b.Audience || !scope.Attenuates(old.InvokeScopes, b.InvokeScopes) || + !scope.Attenuates(old.HeadlessScopes, b.HeadlessScopes) || !scope.Attenuates(old.LookupScopes, b.LookupScopes) { + return true + } + } + if previous.Kind != domain.KindSolution { + return false + } + for name, scopes := range previous.Roles { + if !scope.Attenuates(scopes, next.Roles[name]) { + return true + } + } + return false +} diff --git a/libraries/authority-core/go/domain/domain.go b/libraries/authority-core/go/domain/domain.go new file mode 100644 index 00000000..aa121b02 --- /dev/null +++ b/libraries/authority-core/go/domain/domain.go @@ -0,0 +1,168 @@ +// Package domain is the authority's shared objects: what both hosts (the +// local authority and accounts) store and the decisions read. They carry no +// behaviour of a host: no transport, no store, no key. +package domain + +import ( + "errors" + "fmt" + + resources "github.com/codefly-dev/authority/libraries/resources/go" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// Module kinds and the one reserved role. +const ( + KindModule = "module" + KindSolution = "solution" + InstallerRole = "installer" +) + +// Organization is a tenant. +type Organization struct { + ID, Name string +} + +// Person is a member of one organization: its organization roles (an +// administrator holds "admin" or "owner") and its teams. Identity is the +// host's; the authority reads it. +type Person struct { + ID, Organization string + Roles, Teams []string +} + +// OrgRole is a named set of scopes at one organization. +type OrgRole struct { + Organization, Name string + Scopes []RoleScope +} + +// RoleScope is one organization-role scope: a kind (or AnyKind) and actions, +// optionally narrowed to resources by matcher. +type RoleScope struct { + Kind string + Actions []string + Resources []resources.Grant +} + +// Binding is a module's declared call towards another module. +type Binding struct { + ID, Audience string + InvokeScopes, HeadlessScopes, LookupScopes []*basev0.WorkScopeV1 +} + +// Module is what a delivery composed for one module or solution: its +// principal (the audience every capability to it names), what it serves, its +// bindings, a solution's roles, and whether it checks resource owners. +type Module struct { + Name, Principal, Kind string + Generation uint64 + // StandIn is a module a fixture declares until its own delivery replaces it. + StandIn bool + ResourceKinds []*basev0.WorkScopeV1 + Bindings map[string]Binding + // Roles are a solution's roles: levels on the data its principal owns. + Roles map[string][]*basev0.WorkScopeV1 + // OwnerAware: the module compares a resource's owner with an + // owner-selected scope, so it may receive one. + OwnerAware bool + // Methods are what each method the module serves requires of a call, from + // its delivered authority document; nil when it declared none. + Methods map[string]MethodPolicy +} + +// MethodPolicy is one method's declaration: exactly one of its fields is set. +type MethodPolicy struct { + Requires []*basev0.WorkScopeV1 + AnyCheckedCaller bool +} + +// Status is an installation's lifecycle. +type Status string + +// The three states. +const ( + Pending Status = "pending" + Active Status = "active" + Revoked Status = "revoked" +) + +// Installation is one organization's installation of one target. A +// solution's installation is a principal (PrincipalID): its data is owned by +// it and it acts as it for itself, holding AgentRole. +type Installation struct { + ID, Organization, Target string + Status Status + Revision uint64 + AgentRole string + PrincipalID string + InstalledBy string +} + +// RoleGrant is one role on one installation held by exactly one person or team. +type RoleGrant struct { + InstallationID, Role, PersonID, TeamID string +} + +// Delegation is a person's grant to a module to act for them. +type Delegation struct { + ID, Organization, Grantor, Module, Purpose string + Scopes []*basev0.WorkScopeV1 + Revoked bool +} + +// Code classifies a refusal the way a transport reports it. +type Code int + +// The refusal codes. +const ( + Denied Code = iota // the caller may not + NotFound // the thing named does not exist + Invalid // the request is malformed + Precondition // the state does not allow it now +) + +// Refusal is a decision's named refusal: its code and its reason. +type Refusal struct { + Code Code + Reason string + // Cause is the sentinel a caller may test with errors.Is, when one names + // the refusal. + Cause error +} + +func (r *Refusal) Error() string { return r.Reason } + +// Unwrap is the refusal's sentinel, if any. +func (r *Refusal) Unwrap() error { return r.Cause } + +// ErrNoInstallerRole names a solution that declares no installer role. +var ErrNoInstallerRole = errors.New("a solution declares an installer role") + +// ErrNotFound is what a store answers for a missing record. +var ErrNotFound = errors.New("not found") + +// Refuse builds a Denied refusal. +func Refuse(format string, args ...any) error { return refusal(Denied, format, args...) } + +// Missing builds a NotFound refusal. +func Missing(format string, args ...any) error { return refusal(NotFound, format, args...) } + +// Malformed builds an Invalid refusal. +func Malformed(format string, args ...any) error { return refusal(Invalid, format, args...) } + +// NotNow builds a Precondition refusal. +func NotNow(format string, args ...any) error { return refusal(Precondition, format, args...) } + +func refusal(code Code, format string, args ...any) error { + return &Refusal{Code: code, Reason: fmt.Sprintf(format, args...)} +} + +// CodeOf is err's refusal code, and false for an error that is no refusal. +func CodeOf(err error) (Code, bool) { + var r *Refusal + if errors.As(err, &r) { + return r.Code, true + } + return 0, false +} diff --git a/libraries/authority-core/go/go.mod b/libraries/authority-core/go/go.mod new file mode 100644 index 00000000..4bd0fc18 --- /dev/null +++ b/libraries/authority-core/go/go.mod @@ -0,0 +1,15 @@ +module github.com/codefly-dev/authority/libraries/authority-core/go + +go 1.27.3 + +require ( + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 + github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 + google.golang.org/protobuf v1.36.12 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect +) diff --git a/libraries/authority-core/go/go.sum b/libraries/authority-core/go/go.sum new file mode 100644 index 00000000..9d43f5b0 --- /dev/null +++ b/libraries/authority-core/go/go.sum @@ -0,0 +1,14 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 h1:PnhgA9wkib67bJnc5hkhkc80J2ch5uYiq+9/NiD/XFU= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/libraries/authority-core/go/scope/limits.go b/libraries/authority-core/go/scope/limits.go new file mode 100644 index 00000000..76a68586 --- /dev/null +++ b/libraries/authority-core/go/scope/limits.go @@ -0,0 +1,80 @@ +package scope + +import ( + "sort" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) + +// Limits are lasting bounds on visited kinds, read from a parent capability: +// a narrowing is never recovered by a later hop. A zero-action row is an +// explicit denial; an absent kind has not been constrained. +type Limits map[string][]*basev0.WorkScopeV1 + +// ParentLimits reads every kind the parent's authority and hops constrained. +func ParentLimits(parent *basev0.WorkContextV1) Limits { + sets := [][]*basev0.WorkScopeV1{parent.GetAuthorityScopes()} + for _, actor := range parent.GetActorChain() { + sets = append(sets, actor.GetGrantedScopes()) + } + limits := Limits{} + for _, scopes := range sets { + for kind, rows := range byKind(scopes) { + if previous, ok := limits[kind]; ok { + limits[kind] = Intersect(previous, rows) + } else { + limits[kind] = rows + } + } + } + return limits +} + +// Constrain cuts each scope of a constrained kind to its limit. +func (limits Limits) Constrain(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { + var out []*basev0.WorkScopeV1 + for _, sc := range scopes { + if bound, ok := limits[sc.GetResourceKind()]; ok { + out = append(out, Intersect([]*basev0.WorkScopeV1{sc}, bound)...) + } else { + out = append(out, sc) + } + } + return out +} + +// Project updates only the current binding's kinds, retaining other audience +// limits; an omitted or disjoint target kind stays explicitly denied. +func (limits Limits) Project(domain, granted []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { + for _, sc := range domain { + limits[sc.GetResourceKind()] = nil + } + for kind, rows := range byKind(granted) { + limits[kind] = rows + } + kinds := make([]string, 0, len(limits)) + for kind := range limits { + kinds = append(kinds, kind) + } + sort.Strings(kinds) + out := make([]*basev0.WorkScopeV1, 0, len(limits)) + for _, kind := range kinds { + rows := Normalize(limits[kind]) + if len(rows) == 0 { + rows = []*basev0.WorkScopeV1{{ResourceKind: kind}} + } + out = append(out, rows...) + } + return out +} + +// byKind keeps every action/resource row of a kind together; combining their +// actions and ids separately would authorize the cross product. +func byKind(scopes []*basev0.WorkScopeV1) Limits { + grouped := Limits{} + for _, sc := range scopes { + grouped[sc.GetResourceKind()] = append(grouped[sc.GetResourceKind()], proto.CloneOf(sc)) + } + return grouped +} diff --git a/libraries/authority-core/go/scope/model_test.go b/libraries/authority-core/go/scope/model_test.go new file mode 100644 index 00000000..bc919812 --- /dev/null +++ b/libraries/authority-core/go/scope/model_test.go @@ -0,0 +1,148 @@ +package scope_test + +import ( + "math/rand/v2" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// The reference model: a scope list is the set of points (kind, owner, +// action, id) it covers. Each dimension's universe holds the values the +// generator draws plus one it never draws (an unseen owner, action and id), +// so a whole kind, an owner-blind row and AnyAction each cover something no +// narrower row does, exactly as they do for real. +var ( + kinds = []string{"doc", "note"} + owners = []string{"u", "v", "unseen-owner"} + actions = []string{"read", "write", "unseen-action"} + ids = []string{"x", "y", "unseen-id"} +) + +type point struct{ kind, owner, action, id string } + +func points(scopes []*basev0.WorkScopeV1) map[point]bool { + out := map[point]bool{} + for _, sc := range scopes { + for _, owner := range owners { + if sc.GetResourceOwnerId() != "" && sc.GetResourceOwnerId() != owner { + continue + } + for _, act := range actions { + holds := false + for _, a := range sc.GetActions() { + holds = holds || a == act || a == scope.AnyAction + } + if !holds { + continue + } + for _, id := range ids { + named := len(sc.GetResourceIds()) == 0 + for _, r := range sc.GetResourceIds() { + named = named || r == id + } + if named { + out[point{sc.GetResourceKind(), owner, act, id}] = true + } + } + } + } + } + return out +} + +func within(a, b map[point]bool) bool { + for p := range a { + if !b[p] { + return false + } + } + return true +} + +func randomScopes(r *rand.Rand) []*basev0.WorkScopeV1 { + var out []*basev0.WorkScopeV1 + for range r.IntN(4) { + sc := &basev0.WorkScopeV1{ResourceKind: kinds[r.IntN(len(kinds))]} + if r.IntN(2) == 0 { + sc.ResourceOwnerId = owners[r.IntN(2)] + } + for _, a := range []string{"read", "write", scope.AnyAction} { + if r.IntN(3) == 0 { + sc.Actions = append(sc.Actions, a) + } + } + if len(sc.Actions) == 0 { + sc.Actions = []string{"read"} + } + if r.IntN(2) == 0 { + for _, id := range []string{"x", "y"} { + if r.IntN(2) == 0 { + sc.ResourceIds = append(sc.ResourceIds, id) + } + } + } + out = append(out, sc) + } + return out +} + +// Attenuates answers exactly what the model answers, owned and owner-blind +// rows mixed, AnyAction included: no false yes (a widening) and no false no +// (a legitimate ask refused). +func TestAttenuatesMatchesTheReferenceModel(t *testing.T) { + r := rand.New(rand.NewPCG(1, 2)) + for n := range 200000 { + narrow, wide := randomScopes(r), randomScopes(r) + want := within(points(narrow), points(wide)) + if got := scope.Attenuates(narrow, wide); got != want { + t.Fatalf("case %d: Attenuates(%v, %v) = %v, the model says %v", n, narrow, wide, got, want) + } + } +} + +// Intersect covers exactly the points both sides cover, so an exact row never +// hides an AnyAction row and a repeated row adds to the answer. +func TestIntersectMatchesTheReferenceModel(t *testing.T) { + r := rand.New(rand.NewPCG(3, 4)) + for n := range 200000 { + a, b := randomScopes(r), randomScopes(r) + got, inA, inB := points(scope.Intersect(a, b)), points(a), points(b) + for p := range got { + if !inA[p] || !inB[p] { + t.Fatalf("case %d: Intersect(%v, %v) covers %v, outside one side", n, a, b, p) + } + } + for p := range inA { + if inB[p] && !got[p] && p.action != "unseen-action" { + t.Fatalf("case %d: Intersect(%v, %v) drops %v, which both sides cover", n, a, b, p) + } + } + } +} + +func TestTheCasesTheModelFound(t *testing.T) { + read := []string{"read"} + // The ask equals one held row beside an owned row of the same kind. + if !scope.Attenuates([]*basev0.WorkScopeV1{sc("doc", "", read)}, []*basev0.WorkScopeV1{sc("doc", "", read), sc("doc", "u", read)}) { + t.Fatal("an ask equal to a held row is refused") + } + // An exact row does not hide a wider AnyAction row of the same kind. + got := scope.Intersect([]*basev0.WorkScopeV1{sc("doc", "", read)}, []*basev0.WorkScopeV1{sc("doc", "", read, "x"), sc("doc", "", []string{scope.AnyAction})}) + if len(got) != 1 || len(got[0].GetResourceIds()) != 0 { + t.Fatalf("the exact row hid AnyAction: %v", got) + } + // A repeated row adds to the answer: ids x and y from two rows. + got = scope.Intersect([]*basev0.WorkScopeV1{sc("doc", "", read)}, []*basev0.WorkScopeV1{sc("doc", "", read, "x"), sc("doc", "", read, "y")}) + if len(got) != 1 || len(got[0].GetResourceIds()) != 2 { + t.Fatalf("repeated rows met as %v, want their union", got) + } + // Same compares owners: one row owned by u and one by v are not the same. + if scope.Same([]*basev0.WorkScopeV1{sc("doc", "u", read)}, []*basev0.WorkScopeV1{sc("doc", "v", read)}) { + t.Fatal("two owners are the same") + } + if !scope.Same([]*basev0.WorkScopeV1{sc("doc", "u", read)}, []*basev0.WorkScopeV1{sc("doc", "u", read)}) { + t.Fatal("one owner is not the same as itself") + } +} diff --git a/libraries/authority-core/go/scope/scope.go b/libraries/authority-core/go/scope/scope.go new file mode 100644 index 00000000..9d84dc4c --- /dev/null +++ b/libraries/authority-core/go/scope/scope.go @@ -0,0 +1,287 @@ +// Package scope is the authority's scope algebra: what a set of Work Context +// scopes covers, their intersection and attenuation, and the per-kind limits a +// parent capability imposes on every later hop. It is owner-aware: a scope +// naming a resource owner covers only that owner's resources, and an +// intersection keeps the narrower side, so an owner is added but never dropped. +package scope + +import ( + "errors" + "slices" + "sort" + "strings" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) + +// AnyAction on a served kind serves whatever actions a role grants on it; on +// a role's scope it grants every action the audience serves on that kind. +const AnyAction = "*" + +// AnyKind on a role's scope grants its actions on every kind the audience serves. +const AnyKind = "*" + +// key is one kind as one owner holds it ("" selects no owner). +type key struct{ kind, owner string } + +// coverage is (kind, owner) -> action -> ids, where a nil id set is every +// resource the row selects. +type coverage map[key]map[string]map[string]bool + +func coverageOf(in []*basev0.WorkScopeV1) coverage { + c := coverage{} + for _, sc := range in { + if sc == nil || sc.GetResourceKind() == "" { + continue + } + k := key{sc.GetResourceKind(), sc.GetResourceOwnerId()} + if c[k] == nil { + c[k] = map[string]map[string]bool{} + } + for _, act := range sc.GetActions() { + current, seen := c[k][act] + switch { + case len(sc.GetResourceIds()) == 0: + c[k][act] = nil + case seen && current == nil: + default: + if current == nil { + current = map[string]bool{} + } + for _, id := range sc.GetResourceIds() { + current[id] = true + } + c[k][act] = current + } + } + } + return c +} + +// scopes renders coverage sorted: kinds, then owners, then id sets. +func (c coverage) scopes() []*basev0.WorkScopeV1 { + keys := make([]key, 0, len(c)) + for k := range c { + keys = append(keys, k) + } + sort.Slice(keys, func(a, b int) bool { + if keys[a].kind != keys[b].kind { + return keys[a].kind < keys[b].kind + } + return keys[a].owner < keys[b].owner + }) + var out []*basev0.WorkScopeV1 + for _, k := range keys { + bySet := map[string][]string{} + sets := map[string][]string{} + for act, ids := range c[k] { + set := "" + var list []string + if ids != nil { + for id := range ids { + list = append(list, id) + } + sort.Strings(list) + set = "\x00" + strings.Join(list, "\x00") + } + bySet[set] = append(bySet[set], act) + sets[set] = list + } + names := make([]string, 0, len(bySet)) + for set := range bySet { + names = append(names, set) + } + sort.Strings(names) + for _, set := range names { + actions := bySet[set] + sort.Strings(actions) + out = append(out, &basev0.WorkScopeV1{ResourceKind: k.kind, Actions: actions, ResourceIds: sets[set], ResourceOwnerId: k.owner}) + } + } + return out +} + +// Normalize merges duplicate rows and sorts actions, the shape core requires. +func Normalize(in []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { return coverageOf(in).scopes() } + +// Intersect keeps what a holds that b allows, the narrower side winning: a +// whole kind meets named ids as those ids, two id sets meet as their common +// ids, a row naming no owner meets an owner's row as that owner's, and two +// different owners meet as nothing. AnyAction in b allows every action of a's +// kind; AnyAction in a takes every action b allows on that kind. Where several +// rows of b allow the same (kind, owner, action), the result is the union of +// what each allows: a repeated row adds to the answer, never replaces it. +func Intersect(a, b []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { + allowed, asked := coverageOf(b), coverageOf(a) + out := coverage{} + put := func(k key, act string, ids map[string]bool) { + if ids != nil && len(ids) == 0 { + return + } + if out[k] == nil { + out[k] = map[string]map[string]bool{} + } + if current, seen := out[k][act]; seen { + ids = union(current, ids) + } + out[k][act] = ids + } + for ka, acts := range asked { + for kb, allowedActs := range allowed { + if ka.kind != kb.kind || (ka.owner != "" && kb.owner != "" && ka.owner != kb.owner) { + continue + } + k := key{ka.kind, ka.owner} + if k.owner == "" { + k.owner = kb.owner + } + for act, ids := range acts { + if act == AnyAction { + for served, allowedIDs := range allowedActs { + put(k, served, meet(ids, allowedIDs)) + } + continue + } + // An exact row and an AnyAction row of the same kind both + // allow act: the result is what either allows, so the exact + // row never hides a wider AnyAction row. + if allowedIDs, ok := allowedActs[act]; ok { + put(k, act, meet(ids, allowedIDs)) + } + if allowedIDs, ok := allowedActs[AnyAction]; ok { + put(k, act, meet(ids, allowedIDs)) + } + } + } + } + return out.scopes() +} + +func meet(a, b map[string]bool) map[string]bool { + switch { + case a == nil: + return b + case b == nil: + return a + } + common := map[string]bool{} + for id := range a { + if b[id] { + common[id] = true + } + } + return common +} + +func union(a, b map[string]bool) map[string]bool { + if a == nil || b == nil { + return nil + } + out := map[string]bool{} + for id := range a { + out[id] = true + } + for id := range b { + out[id] = true + } + return out +} + +// Attenuates reports whether every scope in narrow is within wide, decided +// per resource rather than by comparing rendered rows: for each (kind, owner, +// action) narrow names, every resource it selects must be selected by some row +// of wide of that kind, holding that action (or AnyAction), and naming the +// same owner or none. A whole kind is within only a whole kind; an owner-blind +// row is within only an owner-blind row; AnyAction in narrow is within only +// AnyAction in wide. +func Attenuates(narrow, wide []*basev0.WorkScopeV1) bool { + held := coverageOf(wide) + for k, acts := range coverageOf(narrow) { + for act, ids := range acts { + if !covered(held, k, act, ids) { + return false + } + } + } + return true +} + +// covered: every resource ids selects (nil: the whole kind) is held for act at +// k, by rows naming k's owner or no owner and holding act or AnyAction. +func covered(held coverage, k key, act string, ids map[string]bool) bool { + owners, actions := []string{""}, []string{AnyAction} + if k.owner != "" { + owners = append(owners, k.owner) + } + if act != AnyAction { + actions = append(actions, act) + } + var selections []map[string]bool + for _, owner := range owners { + for _, a := range actions { + heldIDs, ok := held[key{k.kind, owner}][a] + if !ok { + continue + } + if heldIDs == nil { + return true + } + selections = append(selections, heldIDs) + } + } + if ids == nil { + return false + } + for id := range ids { + if !slices.ContainsFunc(selections, func(s map[string]bool) bool { return s[id] }) { + return false + } + } + return true +} + +// Same reports whether two normalized scope lists are equal row for row, +// owners included: two rows differing only in their owner are not the same. +func Same(a, b []*basev0.WorkScopeV1) bool { + return slices.EqualFunc(a, b, func(x, y *basev0.WorkScopeV1) bool { return proto.Equal(x, y) }) +} + +// Owned is scopes selecting only owner's resources: the shape every scope a +// solution's role grants takes, on the data its principal owns. An empty owner +// would select every owner's resources, so it is refused, never rendered. +func Owned(scopes []*basev0.WorkScopeV1, owner string) ([]*basev0.WorkScopeV1, error) { + if owner == "" { + return nil, errors.New("scope: an owner-selected scope names its owner; an empty owner would select every owner's resources") + } + out := make([]*basev0.WorkScopeV1, 0, len(scopes)) + for _, sc := range scopes { + c := proto.CloneOf(sc) + c.ResourceOwnerId = owner + out = append(out, c) + } + return Normalize(out), nil +} + +// HoldsAction reports whether held carries action on any resource of kind, +// whatever its ids or owner: a vocabulary check, never resource admission. +func HoldsAction(held []*basev0.WorkScopeV1, kind, action string) bool { + return slices.ContainsFunc(held, func(h *basev0.WorkScopeV1) bool { + return h.GetResourceKind() == kind && (slices.Contains(h.GetActions(), action) || slices.Contains(h.GetActions(), AnyAction)) + }) +} + +// HasOwner reports whether any scope selects a resource owner. +func HasOwner(scopes []*basev0.WorkScopeV1) bool { + return slices.ContainsFunc(scopes, func(s *basev0.WorkScopeV1) bool { return s.GetResourceOwnerId() != "" }) +} + +// Unowned is scopes with their owner dropped: what a kind and its actions +// look like whatever owns them, for a vocabulary comparison only. +func Unowned(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { + out := make([]*basev0.WorkScopeV1, 0, len(scopes)) + for _, sc := range scopes { + out = append(out, &basev0.WorkScopeV1{ResourceKind: sc.GetResourceKind(), Actions: slices.Clone(sc.GetActions())}) + } + return Normalize(out) +} diff --git a/libraries/authority-core/go/scope/scope_test.go b/libraries/authority-core/go/scope/scope_test.go new file mode 100644 index 00000000..b63a1761 --- /dev/null +++ b/libraries/authority-core/go/scope/scope_test.go @@ -0,0 +1,51 @@ +package scope_test + +import ( + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +func sc(kind, owner string, actions []string, ids ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions, ResourceIds: ids, ResourceOwnerId: owner} +} + +func TestOwnerIsANarrowing(t *testing.T) { + read := []string{"read"} + for name, tc := range map[string]struct { + narrow, wide []*basev0.WorkScopeV1 + attenuates bool + }{ + "the same owner": {[]*basev0.WorkScopeV1{sc("doc", "u", read)}, []*basev0.WorkScopeV1{sc("doc", "u", read)}, true}, + "an owner added to a whole kind": {[]*basev0.WorkScopeV1{sc("doc", "u", read)}, []*basev0.WorkScopeV1{sc("doc", "", read)}, true}, + "ids within the owner": {[]*basev0.WorkScopeV1{sc("doc", "u", read, "a")}, []*basev0.WorkScopeV1{sc("doc", "u", read)}, true}, + "another owner": {[]*basev0.WorkScopeV1{sc("doc", "v", read)}, []*basev0.WorkScopeV1{sc("doc", "u", read)}, false}, + "the owner dropped": {[]*basev0.WorkScopeV1{sc("doc", "", read)}, []*basev0.WorkScopeV1{sc("doc", "u", read)}, false}, + "an id without its owner": {[]*basev0.WorkScopeV1{sc("doc", "", read, "a")}, []*basev0.WorkScopeV1{sc("doc", "u", read)}, false}, + "the owner's ids widened": {[]*basev0.WorkScopeV1{sc("doc", "u", read)}, []*basev0.WorkScopeV1{sc("doc", "u", read, "a")}, false}, + } { + t.Run(name, func(t *testing.T) { + if got := scope.Attenuates(tc.narrow, tc.wide); got != tc.attenuates { + t.Fatalf("Attenuates = %v", got) + } + }) + } +} + +func TestIntersectKeepsTheNarrowerOwner(t *testing.T) { + got := scope.Intersect([]*basev0.WorkScopeV1{sc("doc", "", []string{"read", "write"})}, []*basev0.WorkScopeV1{sc("doc", "u", []string{"read"})}) + if len(got) != 1 || got[0].GetResourceOwnerId() != "u" || len(got[0].GetActions()) != 1 { + t.Fatalf("got %v", got) + } + if got := scope.Intersect([]*basev0.WorkScopeV1{sc("doc", "v", []string{"read"})}, []*basev0.WorkScopeV1{sc("doc", "u", []string{"read"})}); len(got) != 0 { + t.Fatalf("two owners met as %v", got) + } + owned, err := scope.Owned([]*basev0.WorkScopeV1{sc("doc", "", []string{"read"})}, "u") + if err != nil || owned[0].GetResourceOwnerId() != "u" || !scope.HasOwner(owned) { + t.Fatalf("owned %v, %v", owned, err) + } + if got, err := scope.Owned([]*basev0.WorkScopeV1{sc("doc", "", []string{"read"})}, ""); err == nil || got != nil { + t.Fatalf("an empty owner rendered %v", got) + } +} diff --git a/libraries/authority-core/library.codefly.yaml b/libraries/authority-core/library.codefly.yaml new file mode 100644 index 00000000..27ef2700 --- /dev/null +++ b/libraries/authority-core/library.codefly.yaml @@ -0,0 +1,12 @@ +# The authority's one generic core: its decisions over shared domain objects, +# then (next) its service behind a Store interface. The local authority wraps it +# with an in-memory store; a host (accounts) imports it with its own. Declared like the +# conformance library. +kind: library +name: authority-core +version: 0.0.0 +languages: + - name: go + path: go/ + exports: + - github.com/codefly-dev/authority/libraries/authority-core/go diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 41c7f67a..3672da43 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -121,13 +121,13 @@ then runs these cases, each making its own grants: 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`. +The modules' side is no longer a target: the readers now honour owners and +their tests run by default. `libraries/hop/go` `owner_test.go` (the stamp never +flattens an owner-selected scope), `libraries/authority-client/go/workcontext` +`owner_test.go` (`AllowsOwned`, `Scopes()` keeps owners, a signed chain dropping +or changing the owner is refused), `libraries/authority-client/python/tests/test_owner.py` +and `libraries/authority-client/typescript/tests/owner.test.mjs` (the same in +Python and TypeScript, through the real decode path). ### Run cases the target supersedes, and how one host passes both @@ -152,9 +152,7 @@ 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. +Run the conformance target with `AUTHORITY_TARGET=solution-principal` from +`modules/authority/services/local/code`: +`go test ./plugins/registry_test -run SolutionPrincipal`. It skips without it +until the local authority implements it. diff --git a/libraries/conformance/go/go.mod b/libraries/conformance/go/go.mod index 19ffbe29..ed296dd7 100644 --- a/libraries/conformance/go/go.mod +++ b/libraries/conformance/go/go.mod @@ -5,7 +5,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) diff --git a/libraries/conformance/go/go.sum b/libraries/conformance/go/go.sum index dce3e94b..d2fa6c9c 100644 --- a/libraries/conformance/go/go.sum +++ b/libraries/conformance/go/go.sum @@ -32,8 +32,8 @@ github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-2026102523 github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7 h1:7vdTj6aLQVyFf0MWWgUnR/otX1v9xN6AbFKC2cHrQjE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261023203940-e720ad2851c7/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/libraries/hop/go/go.mod b/libraries/hop/go/go.mod index c0fcb301..d2cb3555 100644 --- a/libraries/hop/go/go.mod +++ b/libraries/hop/go/go.mod @@ -3,8 +3,8 @@ module github.com/codefly-dev/authority/libraries/hop/go go 1.27.3 require ( - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 ) diff --git a/libraries/hop/go/go.sum b/libraries/hop/go/go.sum index 1edc0d92..a1f68829 100644 --- a/libraries/hop/go/go.sum +++ b/libraries/hop/go/go.sum @@ -28,10 +28,10 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 h1:awoC/NqbOw77H5QLpqx/hnMYpouCb4fnMQXHQow0USM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/libraries/hop/go/hop.go b/libraries/hop/go/hop.go index d7883f49..613aac2f 100644 --- a/libraries/hop/go/hop.go +++ b/libraries/hop/go/hop.go @@ -174,10 +174,13 @@ func IdentityOf(wc *basev0.WorkContextV1) Identity { return id } +// flatten stamps only kind-wide scopes. A resource-limited or owner-selected +// scope is never a kind-wide one; the module reads it from the claims +// (AllowsOwned), which keep its ids and owner. func flatten(scopes []*basev0.WorkScopeV1) []string { var out []string for _, s := range scopes { - if len(s.GetResourceIds()) > 0 { + if len(s.GetResourceIds()) > 0 || s.GetResourceOwnerId() != "" { continue } for _, action := range s.GetActions() { diff --git a/libraries/hop/go/owner_target_test.go b/libraries/hop/go/owner_test.go similarity index 51% rename from libraries/hop/go/owner_target_test.go rename to libraries/hop/go/owner_test.go index fe9fcc3e..f047ef38 100644 --- a/libraries/hop/go/owner_target_test.go +++ b/libraries/hop/go/owner_test.go @@ -2,58 +2,40 @@ 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. +// ownedScope is {kind, [action], resource_owner_id: owner, resource_ids: ids}. 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 + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: []string{action}, ResourceIds: ids, ResourceOwnerId: owner} } -// TARGET (solution principal; conformance RunSolutionPrincipalTarget): run -// with AUTHORITY_TARGET=solution-principal. A module fronted by the hop must +// The solution principal (conformance RunSolutionPrincipalTarget): 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) + t.Fatalf("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) + t.Fatalf("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") + t.Fatal("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") + t.Fatal("the hop's claims must admit the owner's resource and refuse another owner's") } } diff --git a/modules/authority/services/edge/code/go.mod b/modules/authority/services/edge/code/go.mod index 0cedfd23..696e0aa7 100644 --- a/modules/authority/services/edge/code/go.mod +++ b/modules/authority/services/edge/code/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024163421-423f79d08aa1 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 github.com/codefly-dev/sdk-go v0.4.0 google.golang.org/grpc v1.84.0 ) diff --git a/modules/authority/services/edge/code/go.sum b/modules/authority/services/edge/code/go.sum index 5ce30c7c..5e631158 100644 --- a/modules/authority/services/edge/code/go.sum +++ b/modules/authority/services/edge/code/go.sum @@ -28,8 +28,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/modules/authority/services/hop/code/go.mod b/modules/authority/services/hop/code/go.mod index 41d8ff57..f74eae1b 100644 --- a/modules/authority/services/hop/code/go.mod +++ b/modules/authority/services/hop/code/go.mod @@ -8,7 +8,7 @@ require ( connectrpc.com/vanguard v0.4.0 github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0 github.com/codefly-dev/authority/libraries/hop/go v0.0.0 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 github.com/codefly-dev/sdk-go v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/rs/cors v1.11.1 diff --git a/modules/authority/services/hop/code/go.sum b/modules/authority/services/hop/code/go.sum index e4e20248..45ae2276 100644 --- a/modules/authority/services/hop/code/go.sum +++ b/modules/authority/services/hop/code/go.sum @@ -36,8 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 30d96dc6..6fbf3899 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -11,7 +11,7 @@ require ( github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 github.com/codefly-dev/authority/libraries/hop/go v0.0.0 github.com/codefly-dev/authority/libraries/resources/go v0.0.0 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 github.com/codefly-dev/sdk-go v0.4.0 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 @@ -30,7 +30,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect diff --git a/modules/authority/services/local/code/go.sum b/modules/authority/services/local/code/go.sum index c373f102..7b7eb465 100644 --- a/modules/authority/services/local/code/go.sum +++ b/modules/authority/services/local/code/go.sum @@ -38,8 +38,10 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203 h1:XfP9YKR+xDM3n9Fc0O9lNeffd73uynu6Xlg7sPnNZXc= github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025234644-fff5385f9203/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8 h1:awoC/NqbOw77H5QLpqx/hnMYpouCb4fnMQXHQow0USM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261026024334-2737858746e8/go.mod h1:gMef/i8/IgUAeIh6TUeHI1J6160a55Z1XJrMnfjb2d8= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= diff --git a/modules/authority/services/local/code/pkg/business/issuer/issuer.go b/modules/authority/services/local/code/pkg/business/issuer/issuer.go index 606ac8c4..9d14ae1b 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/issuer.go +++ b/modules/authority/services/local/code/pkg/business/issuer/issuer.go @@ -202,6 +202,9 @@ func (i *Issuer) Issue(ctx context.Context, req *authorityv1.IssueRequest) (*aut return nil, err } asked := FromWire(req.GetScopes()) + if err := refuseOwners(asked); err != nil { + return nil, err + } ttl := time.Duration(req.GetTtlSeconds()) * time.Second if ttl <= 0 || ttl > MaxTTL { ttl = MaxTTL @@ -656,6 +659,9 @@ func (i *Issuer) forDelegation(ctx context.Context, id, actorCredential, binding // Delegate records the signed-in person's grant to a module. func (i *Issuer) Delegate(ctx context.Context, module string, scopes []*basev0.WorkScopeV1, purpose string) (state.Delegation, error) { + if err := refuseOwners(scopes); err != nil { + return state.Delegation{}, err + } person, err := i.signedIn(ctx) if err != nil { return state.Delegation{}, err @@ -737,6 +743,19 @@ func grant(asked, held []*basev0.WorkScopeV1) ([]*basev0.WorkScopeV1, error) { return state.Normalize(asked), nil } +// refuseOwners refuses an owner-selected scope by name: this authority's +// algebra cannot compare owners, so honouring one would issue the whole kind, +// wider than asked. It mints owner-selected scopes only once it issues through +// the owner-aware core, behind the audience's owner_aware declaration. +func refuseOwners(scopes []*basev0.WorkScopeV1) error { + for _, sc := range scopes { + if sc.GetResourceOwnerId() != "" { + return fmt.Errorf("%w: %s names resource owner %q; this authority issues no owner-selected scope yet", ErrRefused, sc.GetResourceKind(), sc.GetResourceOwnerId()) + } + } + return nil +} + // ToWire converts core's message to the interface's, byte for byte. func ToWire(in *basev0.WorkContextV1) (*authorityv1.WorkContextV1, error) { raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(in) diff --git a/modules/authority/services/local/code/pkg/business/issuer/owner_refused_test.go b/modules/authority/services/local/code/pkg/business/issuer/owner_refused_test.go new file mode 100644 index 00000000..202dd251 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/owner_refused_test.go @@ -0,0 +1,30 @@ +package issuer_test + +import ( + "errors" + "strings" + "testing" + + "gateway/pkg/business/issuer" + "gateway/pkg/business/testkit" + authorityv1 "gateway/pkg/gen/host/authority/v1" + base "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// The wire carries resource_owner_id, and this authority cannot compare +// owners yet: an owner-selected ask is refused by name, never issued as the +// whole kind, and so is an owner-selected delegation. +func TestAnOwnerSelectedAskIsRefusedNeverWidened(t *testing.T) { + k := testkit.New(t) + owned := []*authorityv1.WorkScopeV1{{ResourceKind: "records.records", Actions: []string{"read"}, ResourceOwnerId: "principal-u"}} + _, err := k.Issuer.Issue(person(t, k, "fixture-maya"), &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "records", Scopes: owned}) + if !errors.Is(err, issuer.ErrRefused) || !strings.Contains(err.Error(), "resource owner") { + t.Fatalf("an owner-selected ask: %v", err) + } + _, err = k.Issuer.Delegate(person(t, k, "fixture-remi"), "org-directory", + []*base.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}, ResourceOwnerId: "principal-u"}}, "owned") + if !errors.Is(err, issuer.ErrRefused) || !strings.Contains(err.Error(), "resource owner") { + t.Fatalf("an owner-selected delegation: %v", err) + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/python_claims_test.go b/modules/authority/services/local/code/pkg/business/issuer/python_claims_test.go index 49fe3c1a..f97ee56a 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/python_claims_test.go +++ b/modules/authority/services/local/code/pkg/business/issuer/python_claims_test.go @@ -2,16 +2,20 @@ package issuer_test import ( "context" + "crypto/ed25519" + "encoding/base64" "encoding/json" "flag" "os" "path/filepath" "reflect" + "slices" "testing" "time" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/workcontext" + "google.golang.org/protobuf/proto" ) var updatePythonClaims = flag.Bool("update-python-claims", false, "regenerate Python claim projection fixtures through Core") @@ -20,7 +24,10 @@ type claimProbe struct { Kind string `json:"kind"` Action string `json:"action"` ResourceID string `json:"resource_id"` - Allowed bool `json:"allowed"` + // Owner, when set, is the resource's owner: the owner-aware question + // (AllowsOwned); without it the probe is the owner-blind Allows. + Owner *string `json:"owner,omitempty"` + Allowed bool `json:"allowed"` } type claimProjection struct { @@ -47,6 +54,9 @@ func TestPythonClaimProjectionFixtures(t *testing.T) { continue } projections = append(projections, coreProjection(t, now, fixture.Name, fixture.Token)) + if fixture.Name == "session" { + projections = append(projections, coreProjection(t, now, "owner-selected", ownerSelected(t, fixture.Token))) + } } data, err := json.MarshalIndent(projections, "", " ") if err != nil { @@ -99,6 +109,7 @@ func coreProjection(t *testing.T, now time.Time, name, token string) claimProjec t.Fatalf("%s: %v", name, err) } scopes := verified.EffectiveScopes() + owned := slices.ContainsFunc(scopes, func(s *basev0.WorkScopeV1) bool { return s.GetResourceOwnerId() != "" }) projection := claimProjection{Name: name, Token: token, Audience: workcontext.FixtureAudience, Scopes: scopes} for _, scope := range scopes { for _, action := range append(append([]string{}, scope.GetActions()...), "undeclared") { @@ -114,9 +125,44 @@ func coreProjection(t *testing.T, now time.Time, name, token string) claimProjec break } } - projection.Probes = append(projection.Probes, claimProbe{scope.GetResourceKind(), action, id, allowed}) + projection.Probes = append(projection.Probes, claimProbe{scope.GetResourceKind(), action, id, nil, allowed}) + if !owned { + continue + } + for _, owner := range []string{fixtureOwner, "principal-other", ""} { + ask := proto.CloneOf(ask) + ask.ResourceOwnerId = owner + allowed := slices.ContainsFunc(scopes, func(c *basev0.WorkScopeV1) bool { return workcontext.ScopeContained(ask, []*basev0.WorkScopeV1{c}) }) + projection.Probes = append(projection.Probes, claimProbe{scope.GetResourceKind(), action, id, &owner, allowed}) + } } } } return projection } + +// fixtureOwner is the principal the owner-selected fixture's scopes select. +const fixtureOwner = "principal-u" + +// ownerSelected is Core's session fixture with owner-selected scopes beside a +// plain one, signed with Core's fixture key: the shape a capability issued +// through a solution carries, which every reader must answer alike. +func ownerSelected(t *testing.T, session string) string { + t.Helper() + decoded, err := workcontext.Decode(session) + if err != nil { + t.Fatal(err) + } + c := decoded.Context() + c.AuthorityScopes = []*basev0.WorkScopeV1{ + {ResourceKind: "record", Actions: []string{"read"}, ResourceOwnerId: fixtureOwner}, + {ResourceKind: "note", Actions: []string{"read"}, ResourceIds: []string{"n1"}, ResourceOwnerId: fixtureOwner}, + {ResourceKind: "plain", Actions: []string{"read"}}, + } + body, err := proto.MarshalOptions{Deterministic: true}.Marshal(c) + if err != nil { + t.Fatal(err) + } + _, key := workcontext.FixtureKeyPair() + return base64.RawURLEncoding.EncodeToString(body) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(key, body)) +} diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go index 95d92acc..046ff401 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go @@ -543,9 +543,15 @@ type WorkScopeV1 struct { Actions []string `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` // resource_ids narrows the scope to specific resources. Empty means every // resource of resource_kind. - ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` + // resource_owner_id narrows the scope to the resources this principal owns + // (an installed solution's principal). Empty means the scope selects no + // owner. With resource_ids, a resource is covered only when both match. A + // reader that cannot compare a resource's owner must not treat an + // owner-selected scope as covering the whole kind. + ResourceOwnerId string `protobuf:"bytes,4,opt,name=resource_owner_id,json=resourceOwnerId,proto3" json:"resource_owner_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkScopeV1) Reset() { @@ -599,6 +605,13 @@ func (x *WorkScopeV1) GetResourceIds() []string { return nil } +func (x *WorkScopeV1) GetResourceOwnerId() string { + if x != nil { + return x.ResourceOwnerId + } + return "" +} + // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's // effective scopes and must monotonically attenuate the previous hop. type WorkActorV1 struct { @@ -1028,12 +1041,13 @@ const file_host_authority_v1_work_context_proto_rawDesc = "" + "binding_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\tbindingId\x12#\n" + "\brevision\x18\x02 \x01(\x04B\a\xbaH\x042\x02(\x01R\brevision\x12)\n" + - "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"{\n" + + "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"\xc0\x01\n" + "\vWorkScopeV1\x12/\n" + "\rresource_kind\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\fresourceKind\x12\x18\n" + "\aactions\x18\x02 \x03(\tR\aactions\x12!\n" + - "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\"\xd0\x03\n" + + "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\x12C\n" + + "\x11resource_owner_id\x18\x04 \x01(\tB\x17\xbaH\x14r\x12\x18\x80\x042\r^[^\\s\\x00*]*$R\x0fresourceOwnerId\"\xd0\x03\n" + "\vWorkActorV1\x12-\n" + "\fprincipal_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\vprincipalId\x121\n" + diff --git a/modules/authority/services/local/openapi/host/authority/v1/authority.swagger.json b/modules/authority/services/local/openapi/host/authority/v1/authority.swagger.json index 834dbfe0..f45d03cb 100644 --- a/modules/authority/services/local/openapi/host/authority/v1/authority.swagger.json +++ b/modules/authority/services/local/openapi/host/authority/v1/authority.swagger.json @@ -510,6 +510,10 @@ "type": "string" }, "description": "resource_ids narrows the scope to specific resources. Empty means every\nresource of resource_kind." + }, + "resourceOwnerId": { + "type": "string", + "description": "resource_owner_id narrows the scope to the resources this principal owns\n(an installed solution's principal). Empty means the scope selects no\nowner. With resource_ids, a resource is covered only when both match. A\nreader that cannot compare a resource's owner must not treat an\nowner-selected scope as covering the whole kind." } }, "description": "WorkScopeV1 is a structured, product-neutral capability scope. Empty\nresource_ids means every resource of resource_kind; a child may narrow that\nwildcard to explicit IDs but may never widen an explicit parent set." diff --git a/modules/authority/services/local/proto/host/authority/v1/work_context.proto b/modules/authority/services/local/proto/host/authority/v1/work_context.proto index 17adac73..cbdb4a9f 100644 --- a/modules/authority/services/local/proto/host/authority/v1/work_context.proto +++ b/modules/authority/services/local/proto/host/authority/v1/work_context.proto @@ -203,6 +203,12 @@ message WorkScopeV1 { // resource_ids narrows the scope to specific resources. Empty means every // resource of resource_kind. repeated string resource_ids = 3; + // resource_owner_id narrows the scope to the resources this principal owns + // (an installed solution's principal). Empty means the scope selects no + // owner. With resource_ids, a resource is covered only when both match. A + // reader that cannot compare a resource's owner must not treat an + // owner-selected scope as covering the whole kind. + string resource_owner_id = 4 [(buf.validate.field).string.max_len = 512, (buf.validate.field).string.pattern = "^[^\\s\\x00*]*$"]; } // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index 50533ae7..b0bd41ad 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -1,5 +1,5 @@ name: local -version: 0.0.0 +version: 0.0.1 agent: kind: codefly:service name: go-grpc diff --git a/modules/console/services/api/code/go.mod b/modules/console/services/api/code/go.mod index 3fcf3a30..1aa9c6ec 100644 --- a/modules/console/services/api/code/go.mod +++ b/modules/console/services/api/code/go.mod @@ -8,7 +8,7 @@ require ( connectrpc.com/vanguard v0.4.0 github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0 github.com/codefly-dev/authority/libraries/hop/go v0.0.0 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 github.com/codefly-dev/sdk-go v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/rs/cors v1.11.1 diff --git a/modules/console/services/api/code/go.sum b/modules/console/services/api/code/go.sum index e4e20248..45ae2276 100644 --- a/modules/console/services/api/code/go.sum +++ b/modules/console/services/api/code/go.sum @@ -36,8 +36,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= -github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435 h1:3M1c4Ver3YRaHj4rk1+mj1uweqTF5bO2QHZ50rI7sNI= +github.com/codefly-dev/core v0.19.7-0.20261026022918-8af4b787a435/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY=