From 1b6d3018ac0c8cce165d94fd8a12899aa30a8120 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:02:35 -0400 Subject: [PATCH 1/4] feat: role scopes limited to what an installation owns A solution's role could grant a consumed scope only on a whole kind, so a viewer role that needed read on the collections its installation owns read every collection in the tenant, and so did robin acting for the viewer. A role now declares installation_owned_scopes (RoleV1 field 3): a kind and actions, no ids. The authority resolves each, for the installation the role is held on, into the same scope limited to the ids that installation owns; a kind it owns nothing of grants nothing, never the whole kind. Ownership is recorded by AuthorityService.AttachResource: the module serving the kind presents the capability the creating call carried, and the owning installation is read from its verified provenance, never named by the caller. Child issuance is unchanged (rule A): the owner's current grants intersected with the binding, parent limits never recovered. The shared conformance suite gains InstallationResources: attachment from provenance and its named refusals, a viewer of A reads exactly A's resources, is refused B's by name, and a narrowed child never widens. authority interface 0.3.0, composition interface 0.2.0. Bindings regenerated with codefly generate proto / generate client (Go, TypeScript); the Python client bindings could not be regenerated (CLI python generation fails to resolve buf/validate), so they stay at the previous contract. Co-Authored-By: Claude Opus 5.5 --- interfaces/authority/interface.codefly.yaml | 4 +- interfaces/composition/interface.codefly.yaml | 2 +- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 374325 -> 377041 bytes .../go/gen/host/authority/v1/authority.pb.go | 207 ++++++++++++++---- .../host/authority/v1/authority_grpc.pb.go | 60 +++++ .../gen/host/composition/v1/composition.pb.go | 51 +++-- .../authority-client/library.codefly.yaml | 8 +- .../src/gen/host/authority/v1/authority_pb.ts | 87 +++++++- .../gen/host/composition/v1/composition_pb.ts | 15 +- libraries/conformance/go/README.md | 1 + libraries/conformance/go/conformance.go | 7 + .../conformance/go/installation_resources.go | 163 ++++++++++++++ modules/authority/module.codefly.yaml | 4 +- .../services/edge/service.codefly.yaml | 2 +- .../services/hop/service.codefly.yaml | 2 +- .../pkg/business/issuer/solution_roles.go | 57 ++++- .../business/state/installation_owned_test.go | 94 ++++++++ .../code/pkg/business/state/permissions.go | 4 +- .../code/pkg/business/state/solution_roles.go | 56 ++++- .../local/code/pkg/business/state/state.go | 34 ++- .../code/pkg/business/testkit/testkit.go | 2 +- .../pkg/gen/host/authority/v1/authority.pb.go | 207 ++++++++++++++---- .../host/authority/v1/authority_grpc.pb.go | 60 +++++ .../authorityv1connect/authority.connect.go | 51 +++++ .../gen/host/composition/v1/composition.pb.go | 51 +++-- .../local/code/plugins/authority/plugin.go | 10 + .../local/code/plugins/composition/plugin.go | 26 ++- .../registry_test/local_authority_test.go | 27 +++ .../proto/host/authority/v1/authority.proto | 30 +++ .../host/composition/v1/composition.proto | 8 + .../console/services/api/service.codefly.yaml | 2 +- 32 files changed, 1186 insertions(+), 148 deletions(-) create mode 100644 libraries/conformance/go/installation_resources.go create mode 100644 modules/authority/services/local/code/pkg/business/state/installation_owned_test.go diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index 72c484f2..5bd71c7e 100644 --- a/interfaces/authority/interface.codefly.yaml +++ b/interfaces/authority/interface.codefly.yaml @@ -2,10 +2,10 @@ kind: interface publisher: codefly.dev name: authority description: What a module or solution calls on its host to be issued, checked and verified authority. Implemented by accounts and by the local authority. -version: 0.2.0 +version: 0.3.0 type: connect protobuf: package: host.authority.v1 services: - name: AuthorityService - methods: [Issue, Check, Keys, ListResources, Delegate, RevokeDelegation, GetDelegation] + methods: [Issue, Check, Keys, ListResources, AttachResource, Delegate, RevokeDelegation, GetDelegation] diff --git a/interfaces/composition/interface.codefly.yaml b/interfaces/composition/interface.codefly.yaml index a42df9e5..72797d86 100644 --- a/interfaces/composition/interface.codefly.yaml +++ b/interfaces/composition/interface.codefly.yaml @@ -2,7 +2,7 @@ kind: interface publisher: codefly.dev name: composition description: How the host learns what is composed; the ceiling is the manifest. -version: 0.1.0 +version: 0.2.0 type: connect protobuf: package: host.composition.v1 diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index f83f7a6c..3e95d385 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:d2fcb57f3608ea6c57918565db2d83cc69d7e9cebee33edadf2160a3fee8d635", + "digest": "sha256:1fc934d90a008264422577981552de8401befcd8f0adcb7603565129b7b2d8b4", "services": [ { "name": "GatewayService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index a9656334556607d45632d69302b914658fa3d918..133aa5311bd7a9b713e133c052f35b199b4505de 100644 GIT binary patch delta 6805 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 YH8Lz(%b#U_d&eSJo*n(uuvpjlKOyZ3o&W#< delta 4306 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 host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 19, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 21, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 19, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 19, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 21, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 21, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 20, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 20, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 22, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 22, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 18, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 0, // 14: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 15: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 16: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 17: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 2, // 18: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 19: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 20: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 21: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 22: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 23: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 24: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 3, // 25: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 26: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 27: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 21, // [21:28] is the sub-list for method output_type - 14, // [14:21] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 20, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 15: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 16: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 17: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 18: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 19: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 2, // 20: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 21: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 22: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 23: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 24: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 25: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 26: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 27: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 3, // 28: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 29: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 30: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 23, // [23:31] is the sub-list for method output_type + 15, // [15:23] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1522,7 +1655,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[18].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[20].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1534,7 +1667,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 19, + NumMessages: 21, NumExtensions: 0, NumServices: 1, }, diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go index 51d772ee..c9dc897a 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go @@ -30,6 +30,7 @@ const ( AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" + AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" @@ -57,6 +58,18 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) @@ -117,6 +130,16 @@ func (c *authorityServiceClient) ListResources(ctx context.Context, in *ListReso return out, nil } +func (c *authorityServiceClient) AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AttachResourceResponse) + err := c.cc.Invoke(ctx, AuthorityService_AttachResource_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Delegation) @@ -169,6 +192,18 @@ type AuthorityServiceServer interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *DelegateRequest) (*Delegation, error) @@ -201,6 +236,9 @@ func (UnimplementedAuthorityServiceServer) Keys(context.Context, *KeysRequest) ( func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") } +func (UnimplementedAuthorityServiceServer) AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AttachResource not implemented") +} func (UnimplementedAuthorityServiceServer) Delegate(context.Context, *DelegateRequest) (*Delegation, error) { return nil, status.Error(codes.Unimplemented, "method Delegate not implemented") } @@ -303,6 +341,24 @@ func _AuthorityService_ListResources_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _AuthorityService_AttachResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AttachResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).AttachResource(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_AttachResource_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).AttachResource(ctx, req.(*AttachResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_Delegate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DelegateRequest) if err := dec(in); err != nil { @@ -380,6 +436,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, }, + { + MethodName: "AttachResource", + Handler: _AuthorityService_AttachResource_Handler, + }, { MethodName: "Delegate", Handler: _AuthorityService_Delegate_Handler, diff --git a/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go b/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go index 606daa51..b98e5f26 100644 --- a/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go +++ b/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go @@ -512,11 +512,19 @@ func (x *ModuleManifestV1) GetRoles() []*RoleV1 { // RoleV1 is one role a solution declares. type RoleV1 struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` + // Scopes the role grants only on the resources the installation owns: those + // created or attached through it (AuthorityService.AttachResource). Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + InstallationOwnedScopes []*v1.WorkScopeV1 `protobuf:"bytes,3,rep,name=installation_owned_scopes,json=installationOwnedScopes,proto3" json:"installation_owned_scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoleV1) Reset() { @@ -563,6 +571,13 @@ func (x *RoleV1) GetScopes() []*v1.WorkScopeV1 { return nil } +func (x *RoleV1) GetInstallationOwnedScopes() []*v1.WorkScopeV1 { + if x != nil { + return x.InstallationOwnedScopes + } + return nil +} + // BindingV1 is one declared call towards another module. type BindingV1 struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -802,10 +817,11 @@ const file_host_composition_v1_composition_proto_rawDesc = "" + "\bbindings\x18\x05 \x03(\v2\x1e.host.composition.v1.BindingV1R\bbindings\x12J\n" + "\x0eresource_kinds\x18\x06 \x03(\v2#.host.composition.v1.ResourceKindV1R\rresourceKinds\x12/\n" + "\x04page\x18\a \x01(\v2\x1b.host.composition.v1.PageV1R\x04page\x121\n" + - "\x05roles\x18\b \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"T\n" + + "\x05roles\x18\b \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"\xb0\x01\n" + "\x06RoleV1\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + - "\x06scopes\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x06scopes\"\x8a\x02\n" + + "\x06scopes\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12Z\n" + + "\x19installation_owned_scopes\x18\x03 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x17installationOwnedScopes\"\x8a\x02\n" + "\tBindingV1\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12C\n" + @@ -861,16 +877,17 @@ var file_host_composition_v1_composition_proto_depIdxs = []int32{ 9, // 9: host.composition.v1.ModuleManifestV1.page:type_name -> host.composition.v1.PageV1 6, // 10: host.composition.v1.ModuleManifestV1.roles:type_name -> host.composition.v1.RoleV1 10, // 11: host.composition.v1.RoleV1.scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 12: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 13: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 14: host.composition.v1.BindingV1.lookup_scopes:type_name -> host.authority.v1.WorkScopeV1 - 0, // 15: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest - 4, // 16: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse - 16, // [16:17] is the sub-list for method output_type - 15, // [15:16] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 10, // 12: host.composition.v1.RoleV1.installation_owned_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 13: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 14: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 15: host.composition.v1.BindingV1.lookup_scopes:type_name -> host.authority.v1.WorkScopeV1 + 0, // 16: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest + 4, // 17: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse + 17, // [17:18] is the sub-list for method output_type + 16, // [16:17] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_host_composition_v1_composition_proto_init() } diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 1af6d794..7b8c28a8 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -6,10 +6,6 @@ languages: path: go/ exports: - github.com/codefly-dev/authority/libraries/authority-client/go - - name: python - path: python/ - exports: - - authority_client - name: typescript path: typescript/ exports: @@ -20,10 +16,10 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:d2fcb57f3608ea6c57918565db2d83cc69d7e9cebee33edadf2160a3fee8d635 + contract-digest: sha256:1fc934d90a008264422577981552de8401befcd8f0adcb7603565129b7b2d8b4 services: - GatewayService generated-by: codefly: development - companion: ghcr.io/codefly-dev/proto:0.0.17 + companion: ghcr.io/codefly-dev/proto:0.0.16 facade: false diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts index ec333289..2b110536 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCJaCg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAQgcKBW1hdGNoMqwFChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USTQoIRGVsZWdhdGUSIi5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0ZVJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uEmsKEFJldm9rZURlbGVnYXRpb24SKi5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBorLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXNwb25zZRJXCg1HZXREZWxlZ2F0aW9uEicuaG9zdC5hdXRob3JpdHkudjEuR2V0RGVsZWdhdGlvblJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uQi9aLWdhdGV3YXkvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MWIGcHJvdG8z", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiWgoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIAEIHCgVtYXRjaDKTBgoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USYgoNTGlzdFJlc291cmNlcxInLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXF1ZXN0GiguaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1Jlc3BvbnNlEmUKDkF0dGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuQXR0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuQXR0YWNoUmVzb3VyY2VSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -647,6 +647,69 @@ export type ListResourcesResponse = Message<"host.authority.v1.ListResourcesResp export const ListResourcesResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_host_authority_v1_authority, 17); +/** + * AttachResourceRequest names one resource and the capability it was created under. + * + * @generated from message host.authority.v1.AttachResourceRequest + */ +export type AttachResourceRequest = Message<"host.authority.v1.AttachResourceRequest"> & { + /** + * The capability the creating call carried, addressed to the caller. + * + * @generated from field: string token = 1; + */ + token: string; + + /** + * The module serving resource_kind, proving which module it is. + * + * @generated from field: host.authority.v1.WorkloadCredential actor = 2; + */ + actor?: WorkloadCredential; + + /** + * The resource kind, as the caller serves it. + * + * @generated from field: string resource_kind = 3; + */ + resourceKind: string; + + /** + * The resource id, as the caller knows it. + * + * @generated from field: string resource_id = 4; + */ + resourceId: string; +}; + +/** + * Describes the message host.authority.v1.AttachResourceRequest. + * Use `create(AttachResourceRequestSchema)` to create a new message. + */ +export const AttachResourceRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 18); + +/** + * AttachResourceResponse names the installation that now owns the resource. + * + * @generated from message host.authority.v1.AttachResourceResponse + */ +export type AttachResourceResponse = Message<"host.authority.v1.AttachResourceResponse"> & { + /** + * The owning installation, read from the capability's provenance. + * + * @generated from field: string installation_id = 1; + */ + installationId: string; +}; + +/** + * Describes the message host.authority.v1.AttachResourceResponse. + * Use `create(AttachResourceResponseSchema)` to create a new message. + */ +export const AttachResourceResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 19); + /** * ResourceGrant is one way of naming covered resources. Exactly one is set. * @@ -697,7 +760,7 @@ export type ResourceGrant = Message<"host.authority.v1.ResourceGrant"> & { * Use `create(ResourceGrantSchema)` to create a new message. */ export const ResourceGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 18); + messageDesc(file_host_authority_v1_authority, 20); /** * AuthorityService is implemented by the host (accounts) and by the local authority. @@ -753,6 +816,26 @@ export const AuthorityService: GenService<{ input: typeof ListResourcesRequestSchema; output: typeof ListResourcesResponseSchema; }, + /** + * AttachResource records that one resource was created or attached through + * an installation, so a role scope its solution declares installation-owned + * (RoleV1.installation_owned_scopes) covers it. The caller is the module that + * serves the kind, proving itself with its workload credential, and presents + * the capability the creating call carried, addressed to it and holding an + * action on the kind. The owning installation is read from that capability's + * verified provenance and never named by the caller: the installation itself + * when it is the owner, otherwise the one solution in its actor chain. A + * capability issued through no installation, or through two, is refused. + * Attaching only widens what an installation-owned scope covers, so it moves + * no authorization revision; a reinstalled installation owns nothing. + * + * @generated from rpc host.authority.v1.AuthorityService.AttachResource + */ + attachResource: { + methodKind: "unary"; + input: typeof AttachResourceRequestSchema; + output: typeof AttachResourceResponseSchema; + }, /** * Delegate records a signed-in person's grant to a module (the caller's * bearer is the person); Issue with a delegation acts on it. diff --git a/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts b/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts index eab14d07..d903a4e4 100644 --- a/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/composition/v1/composition.proto. */ export const file_host_composition_v1_composition: GenFile = /*@__PURE__*/ - fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIvEBChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJGCgZSb2xlVjESDAoEbmFtZRgBIAEoCRIuCgZzY29wZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSLQAQoJQmluZGluZ1YxEgoKAmlkGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEjUKDWludm9rZV9zY29wZXMYAyADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI3Cg9oZWFkbGVzc19zY29wZXMYBCADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI1Cg1sb29rdXBfc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjEiLwoOUmVzb3VyY2VLaW5kVjESDAoEa2luZBgBIAEoCRIPCgdhY3Rpb25zGAIgAygJIiYKBlBhZ2VWMRINCgV0aXRsZRgBIAEoCRINCgVvcmRlchgCIAEoBTJ8ChJDb21wb3NpdGlvblNlcnZpY2USZgoNQXBwbHlNYW5pZmVzdBIpLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlcXVlc3QaKi5ob3N0LmNvbXBvc2l0aW9uLnYxLkFwcGx5TWFuaWZlc3RSZXNwb25zZUIzWjFnYXRld2F5L3BrZy9nZW4vaG9zdC9jb21wb3NpdGlvbi92MTtjb21wb3NpdGlvbnYxYgZwcm90bzM", [file_host_authority_v1_work_context]); + fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIvEBChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSKJAQoGUm9sZVYxEgwKBG5hbWUYASABKAkSLgoGc2NvcGVzGAIgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESQQoZaW5zdGFsbGF0aW9uX293bmVkX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxItABCglCaW5kaW5nVjESCgoCaWQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSNQoNaW52b2tlX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjcKD2hlYWRsZXNzX3Njb3BlcxgEIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjUKDWxvb2t1cF9zY29wZXMYBSADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSIvCg5SZXNvdXJjZUtpbmRWMRIMCgRraW5kGAEgASgJEg8KB2FjdGlvbnMYAiADKAkiJgoGUGFnZVYxEg0KBXRpdGxlGAEgASgJEg0KBW9yZGVyGAIgASgFMnwKEkNvbXBvc2l0aW9uU2VydmljZRJmCg1BcHBseU1hbmlmZXN0EikuaG9zdC5jb21wb3NpdGlvbi52MS5BcHBseU1hbmlmZXN0UmVxdWVzdBoqLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlc3BvbnNlQjNaMWdhdGV3YXkvcGtnL2dlbi9ob3N0L2NvbXBvc2l0aW9uL3YxO2NvbXBvc2l0aW9udjFiBnByb3RvMw", [file_host_authority_v1_work_context]); /** * ApplyManifestRequest carries the manifest. @@ -324,6 +324,19 @@ export type RoleV1 = Message<"host.composition.v1.RoleV1"> & { * @generated from field: repeated host.authority.v1.WorkScopeV1 scopes = 2; */ scopes: WorkScopeV1[]; + + /** + * Scopes the role grants only on the resources the installation owns: those + * created or attached through it (AuthorityService.AttachResource). Each + * names a kind and actions and no resource ids, since ids are per + * installation, and is held to the same ceiling as scopes. The authority + * resolves each, for the installation the role is held on, into the same + * scope limited to the ids that installation owns of the kind; a kind it owns + * nothing of grants nothing, and never the whole kind. + * + * @generated from field: repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; + */ + installationOwnedScopes: WorkScopeV1[]; }; /** diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index df030e98..8623e3f8 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -24,6 +24,7 @@ providing different labels does not remove an obligation. | Delivered solution roles at their own audience, binding roots and children; no-role refusals; ceiling enforcement | `SolutionRoles`: a signed wiki viewer role grants Maya documents write, which she lacks independently. Replayed deliveries use deterministic authority-document bytes. | | Rule A: current owner grants intersected with each target binding; canonical `//` identity; named hop refusals | `Chain`, `ChildScopes`, and the individual binding-refusal cases | | Enduring narrowing through two hops | `ChildScopes` and `ParentLimits`: stored delegations retain complete ceilings; parent action/resource limits and entirely dropped kinds remain capped across target changes. Multiple action/resource rows must not become a cross product. | +| Installation-owned role scopes: a role scope limited to what the installation owns, attached from the creating capability's provenance; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens | `InstallationResources`: two installed solutions, A (the viewer's) and B, and a store module serving the kind; the suite delivers all three | | Installation identity, installed caller, explicit organization, pending, active and revoked states | `InstalledSolution`, `InstalledCredential`, `Lifecycle` | | Absent installation versus no entitlement | `FailedPrecondition` with the absent-installation reason versus `PermissionDenied` with the holds-nothing reason | | Inventory access | Admin and owner allowed; ordinary member, anonymous and foreign-organization reads refused | diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index b52d92e0..f9f63033 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -92,6 +92,10 @@ type Fixture struct { ChildScopes *ChildScopesCase // ParentLimits proves persistent per-kind limits, including entirely dropped kinds. ParentLimits *ParentLimitsCase + // InstallationResources proves role scopes a solution declares + // installation-owned: resolved to what each installation owns, never the + // whole kind, and never widened by a child. Omission fails conformance. + InstallationResources *InstallationResourcesCase // InstallationChain exercises successive module actors under one installed // solution's role. Omission fails conformance. InstallationChain *InstallationChainCase @@ -589,6 +593,9 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt } c.installationChain(t, ctx, *f.InstallationChain) }) + t.Run("installation-owned role scopes cover only what the installation owns", func(t *testing.T) { + c.installationResources(t, ctx, f) + }) t.Run("a check naming a method applies what the module's delivery declares", func(t *testing.T) { if f.SignManifest == nil || f.SignAuthority == nil { t.Fatal("required conformance fixture missing: the fixture signs no authority document") diff --git a/libraries/conformance/go/installation_resources.go b/libraries/conformance/go/installation_resources.go new file mode 100644 index 00000000..4358bab7 --- /dev/null +++ b/libraries/conformance/go/installation_resources.go @@ -0,0 +1,163 @@ +package conformance + +import ( + "context" + "slices" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// InstallationResourcesCase proves a role scope a solution declares +// installation-owned. The host seeds two active installations, A and B, of two +// solutions at Organization: Owner installed both, and Viewer is entitled to A +// alone, through A's viewer role. The suite delivers both solutions and Store, +// the module serving Kind, so their declarations are exactly the suite's. +type InstallationResourcesCase struct { + Owner, Viewer string // fixture tokens + A, B OwnedInstallation + // Store is the module serving Kind; StoreCredential is its workload. + Store, StoreCredential, Kind string + // Generation the suite delivers Store, A and B at. + Generation uint64 + // ResourceA and ResourceA2 are created through A, ResourceB through B. + ResourceA, ResourceA2, ResourceB string +} + +// OwnedInstallation is one installed solution of the case. +type OwnedInstallation struct { + Solution, Credential, Installation string +} + +const ownedOwnKind = "conformance.owned-note" + +func (c *client) installationResources(t *testing.T, ctx context.Context, fixture Fixture) { + f := fixture.InstallationResources + if f == nil || fixture.SignManifest == nil || fixture.SignAuthority == nil || f.Owner == "" || f.Viewer == "" || f.A.Installation == "" || f.B.Installation == "" || + f.StoreCredential == "" || f.Generation == 0 || f.ResourceA == "" || f.ResourceA == f.ResourceA2 || f.ResourceA == f.ResourceB || f.ResourceA2 == f.ResourceB { + t.Fatal("required conformance fixture missing: signed delivery, two owned installations and three distinct resources") + } + const binding, self = "store", "self" + create, read := scopeAsk(f.Kind, []string{"create"}, nil), scopeAsk(f.Kind, []string{"read"}, nil) + // Store serves Kind; its self binding lets it hop on, and work headless. + if _, err := c.deliver(ctx, fixture, f.Store, "module", f.Generation, []string{f.Kind}, + map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, []string{"create", "read"}, nil)}}, 0, + solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read"}, "headless": {f.Kind + ":read"}}}); err != nil { + t.Fatalf("deliver %s: %v", f.Store, err) + } + // Each installer may create anywhere; each role reads only what its own + // installation owns. + own := scopeAsk(ownedOwnKind, []string{"read"}, nil) + doc := map[string]any{ + "scope_ceilings": []any{own}, + "roles": []any{ + map[string]any{"name": "installer", "scopes": []any{own, create}, "installation_owned_scopes": []any{read}}, + map[string]any{"name": "viewer", "scopes": []any{own}, "installation_owned_scopes": []any{read}}, + }, + } + for _, s := range []OwnedInstallation{f.A, f.B} { + if _, err := c.deliver(ctx, fixture, s.Solution, "solution", f.Generation, []string{ownedOwnKind}, doc, 0, + solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":create", f.Kind + ":read"}}}); err != nil { + t.Fatalf("deliver %s: %v", s.Solution, err) + } + } + signIn := func(token string) context.Context { + return as(ctx, c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": token})) + } + owner, viewer := signIn(f.Owner), signIn(f.Viewer) + issue := func(fields map[string]any) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "Issue", fields) + } + // through issues person at s's root, then s's hop to Store asking scopes. + through := func(t *testing.T, person context.Context, s OwnedInstallation, scopes ...any) (protoreflect.Message, error) { + t.Helper() + root := c.must(t, person, "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": s.Solution}) + return issue(map[string]any{"parent_token": str(root, "token"), "actor": map[string]any{"credential": s.Credential}, + "operation_binding_id": binding, "audience": f.Store, "scopes": scopes}) + } + hop := func(parent protoreflect.Message, scopes ...any) (protoreflect.Message, error) { + return issue(map[string]any{"parent_token": str(parent, "token"), "actor": map[string]any{"credential": f.StoreCredential}, + "operation_binding_id": self, "audience": f.Store, "scopes": scopes}) + } + attach := func(token, credential, id string) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "AttachResource", map[string]any{"token": token, + "actor": map[string]any{"credential": credential}, "resource_kind": f.Kind, "resource_id": id}) + } + + t.Run("a resource created through an installation is attached to it, read from provenance", func(t *testing.T) { + for s, ids := range map[OwnedInstallation][]string{f.A: {f.ResourceA, f.ResourceA2}, f.B: {f.ResourceB}} { + created, err := through(t, owner, s, create) + if err != nil { + t.Fatal(err) + } + for _, id := range ids { + out, err := attach(str(created, "token"), f.StoreCredential, id) + if err != nil { + t.Fatal(err) + } + if got := str(out, "installation_id"); got != s.Installation { + t.Fatalf("%s attached to %s, want %s", id, got, s.Installation) + } + } + } + }) + t.Run("an attachment is refused without a serving module or an installation, by name", func(t *testing.T) { + created, err := through(t, owner, f.A, create) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(created, "token"), f.A.Credential, f.ResourceB) + refused(t, err, codes.PermissionDenied, "does not serve") + headless, err := issue(map[string]any{"workload": map[string]any{"credential": f.StoreCredential}, "organization_id": fixture.Organization, + "operation_binding_id": self, "audience": f.Store, "operation": "headless"}) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(headless, "token"), f.StoreCredential, f.ResourceB) + refused(t, err, codes.PermissionDenied, "no installation") + }) + t.Run("a viewer of an installation reads exactly what it owns", func(t *testing.T) { + out, err := through(t, viewer, f.A) + if err != nil { + t.Fatal(err) + } + exactEffectiveScope(t, msg(out, "context"), f.Kind, []string{"read"}, []string{f.ResourceA, f.ResourceA2}) + c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": f.Store}) + listed := c.must(t, ctx, "host.authority.v1.AuthorityService", "ListResources", map[string]any{"token": str(out, "token"), "audience": f.Store, "resource_kind": f.Kind, "action": "read"}) + var ids []string + for _, g := range grantsOf(listed) { + if g.ID == "" { + t.Fatalf("an installation-owned read listed a grant beyond ids: %+v", g) + } + ids = append(ids, g.ID) + } + if slices.Sort(ids); !slices.Equal(ids, slices.Sorted(slices.Values([]string{f.ResourceA, f.ResourceA2}))) { + t.Fatalf("listed %v, want exactly %s's resources", ids, f.A.Installation) + } + }) + t.Run("the same viewer is refused another installation's resource, by name", func(t *testing.T) { + _, err := through(t, viewer, f.A, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceB})) + refused(t, err, codes.PermissionDenied, "widens") + _, err = through(t, viewer, f.A, read) + refused(t, err, codes.PermissionDenied, "widens") + _, err = c.call(viewer, "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": f.B.Solution}) + refused(t, err, codes.PermissionDenied, "holds nothing") + }) + t.Run("a child context never widens an installation-owned grant it narrowed", func(t *testing.T) { + narrowed, err := through(t, viewer, f.A, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceA})) + if err != nil { + t.Fatal(err) + } + next, err := hop(narrowed) + if err != nil { + t.Fatal(err) + } + exactEffectiveScope(t, msg(next, "context"), f.Kind, []string{"read"}, []string{f.ResourceA}) + for _, ask := range []any{scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceA2}), scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceB}), read} { + _, err := hop(narrowed, ask) + refused(t, err, codes.PermissionDenied, "widens") + } + }) +} diff --git a/modules/authority/module.codefly.yaml b/modules/authority/module.codefly.yaml index 6b7eb64c..ae511ef1 100644 --- a/modules/authority/module.codefly.yaml +++ b/modules/authority/module.codefly.yaml @@ -8,8 +8,8 @@ interface: - service: local endpoint: grpc implements: - - codefly.dev/authority@0.2.0 - - codefly.dev/composition@0.1.0 + - codefly.dev/authority@0.3.0 + - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.1.0 - codefly.dev/session@0.2.0 # The REST face publishes the authority's keys as a JSON Web Key Set diff --git a/modules/authority/services/edge/service.codefly.yaml b/modules/authority/services/edge/service.codefly.yaml index 617d07b4..b88eabb6 100644 --- a/modules/authority/services/edge/service.codefly.yaml +++ b/modules/authority/services/edge/service.codefly.yaml @@ -15,7 +15,7 @@ agent: # saas accounts implements the same interfaces — and the local authority that # implements all three when it names none (code/host.go). service-dependencies: - - interface: codefly.dev/authority@0.2.0 + - interface: codefly.dev/authority@0.3.0 - interface: codefly.dev/session@0.2.0 - interface: codefly.dev/installation@0.1.0 workspace-configuration-dependencies: diff --git a/modules/authority/services/hop/service.codefly.yaml b/modules/authority/services/hop/service.codefly.yaml index b8c73495..0688d89b 100644 --- a/modules/authority/services/hop/service.codefly.yaml +++ b/modules/authority/services/hop/service.codefly.yaml @@ -35,7 +35,7 @@ spec: # the composition names in the authority group's endpoint answers (saas # accounts, say), and the local authority when it names none (code/host.go). service-dependencies: - - interface: codefly.dev/authority@0.2.0 + - interface: codefly.dev/authority@0.3.0 # The hop group: the run's proof, which the hop writes on every stamp so the # module behind it believes only stamps from this run's hop, and the module the # composition puts the hop in front of (MODULES). hop-routes is generated diff --git a/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go index a16f7be9..71d14204 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go +++ b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go @@ -1,10 +1,14 @@ package issuer import ( + "context" + "fmt" + "slices" + "strings" + "gateway/pkg/business/state" resources "github.com/codefly-dev/authority/libraries/resources/go" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "slices" ) // solutionPrincipals reads provenance already verified by Core. A module hop @@ -88,3 +92,54 @@ func roleResources(scopes []*basev0.WorkScopeV1, kind, action string) []resource } return grants } + +// AttachResource records that the module serving kind created or attached id +// under token, a capability addressed to that module and holding an action on +// kind. The owning installation is read from the capability's verified +// provenance, never named by the caller: an installation owning it, and the +// installation of each solution in its actor chain. The state requires one. +func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id string) (string, error) { + if kind == "" || id == "" { + return "", fmt.Errorf("%w: an attachment names a resource kind and a resource id", ErrRefused) + } + actor, err := i.state.VerifyWorkload(ctx, credential) + if err != nil { + return "", err + } + if !holdsKind(actor.ResourceKinds, kind) { + return "", fmt.Errorf("%w: %s does not serve %s, so it attaches none of its resources", ErrRefused, actor.Module, kind) + } + verified, err := i.verifier(actor.Principal).Verify(ctx, token) + if err != nil { + return "", err + } + if !holdsKind(verified.EffectiveScopes(), kind) { + return "", fmt.Errorf("%w: the capability holds no action on %s, so nothing of it was created under it", ErrRefused, kind) + } + wc := verified.Context() + var candidates []string + if wc.GetOwnerPrincipalKind() == "agent" { + candidates = append(candidates, strings.TrimSuffix(wc.GetOwnerPrincipalId(), "/agent")) + } + for _, hop := range wc.GetActorChain() { + if hop.GetPrincipalKind() != "service" { + continue + } + m, err := i.state.ModuleByPrincipal(hop.GetPrincipalId()) + if err != nil { + return "", err + } + if m.Kind == "solution" { + candidates = append(candidates, state.InstallationID(wc.GetTenantId(), m.Module)) + } + } + return i.state.AttachResource(wc.GetTenantId(), candidates, kind, id) +} + +// holdsKind reports whether any scope grants an action on kind; a zero-action +// row is a denial, not a grant. +func holdsKind(scopes []*basev0.WorkScopeV1, kind string) bool { + return slices.ContainsFunc(scopes, func(scope *basev0.WorkScopeV1) bool { + return scope.GetResourceKind() == kind && len(scope.GetActions()) > 0 + }) +} diff --git a/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go new file mode 100644 index 00000000..adbb9caf --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go @@ -0,0 +1,94 @@ +package state_test + +import ( + "context" + "errors" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" +) + +// ownedWiki consumes documents.collection read only on what an installation owns. +func ownedWiki(generation uint64) state.Manifest { + m := testkit.Wiki(generation) + m.Bindings = map[string]state.Binding{"documents": {ID: "documents", Audience: "documents", InvokeScopes: []*basev0.WorkScopeV1{testkit.Scope("documents.collection", "read")}}} + m.OwnedRoles = map[string][]*basev0.WorkScopeV1{"viewer": {testkit.Scope("documents.collection", "read")}} + return m +} + +func TestAnInstallationOwnedScopeIsDeclaredWithoutIDsOnADeclaredRole(t *testing.T) { + k := testkit.New(t) + withIDs := ownedWiki(2) + withIDs.OwnedRoles["viewer"][0].ResourceIds = []string{"finance"} + undeclared := ownedWiki(2) + undeclared.OwnedRoles["auditor"] = undeclared.OwnedRoles["viewer"] + beyond := ownedWiki(2) + beyond.OwnedRoles["viewer"] = []*basev0.WorkScopeV1{testkit.Scope("documents.collection", "admin")} + module := testkit.Documents(2) + module.OwnedRoles = map[string][]*basev0.WorkScopeV1{"viewer": {testkit.Scope("documents", "read")}} + for name, m := range map[string]state.Manifest{"ids": withIDs, "undeclared role": undeclared, "beyond the binding": beyond, "a module": module} { + if _, err := k.State.ApplyManifest(m); !errors.Is(err, state.ErrForbidden) { + t.Errorf("%s: want ErrForbidden, got %v", name, err) + } + } + onlyOwned := ownedWiki(2) + onlyOwned.Roles["viewer"] = nil + if _, err := k.State.ApplyManifest(onlyOwned); err != nil { + t.Fatalf("a role granting only installation-owned scopes is a role: %v", err) + } +} + +func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) { + k := testkit.New(t) + if _, err := k.State.ApplyManifest(ownedWiki(2)); err != nil { + t.Fatal(err) + } + id := state.InstallationID("acme", "wiki") + held := func() []*basev0.WorkScopeV1 { + t.Helper() + scopes, err := k.State.PersonSolutionScopes("maya", "wiki") + if err != nil { + t.Fatal(err) + } + return state.Intersect(scopes, []*basev0.WorkScopeV1{testkit.Scope("documents.collection", "read")}) + } + if got := held(); len(got) != 0 { + t.Fatalf("an installation owning nothing grants nothing, never the whole kind: %v", got) + } + if _, err := k.State.AttachResource("acme", nil, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("no installation: %v", err) + } + if _, err := k.State.AttachResource("acme", []string{id, state.InstallationID("acme", "proposals")}, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("two installations: %v", err) + } + if _, err := k.State.AttachResource("globex", []string{id}, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("another organization: %v", err) + } + before, _ := k.State.Installation(id) + for range 2 { + if _, err := k.State.AttachResource("acme", []string{id}, "documents.collection", "c1"); err != nil { + t.Fatal(err) + } + } + if got := held(); len(got) != 1 || len(got[0].GetResourceIds()) != 1 || got[0].GetResourceIds()[0] != "c1" { + t.Fatalf("held %v, want read on c1 alone", got) + } + if len(before.Owned) != 0 { + t.Fatalf("a copy read before the attachment changed under its reader: %v", before.Owned) + } + if err := k.State.Revoke(id); err != nil { + t.Fatal(err) + } + if _, err := k.State.Install(context.Background(), "acme", "wiki", "viewer", "alex"); err != nil { + t.Fatal(err) + } + if _, err := k.State.GrantToTeam(context.Background(), "acme", id, "finance"); err != nil { + t.Fatal(err) + } + if got := held(); len(got) != 0 { + t.Fatalf("a reinstalled installation inherited its predecessor's resources: %v", got) + } +} diff --git a/modules/authority/services/local/code/pkg/business/state/permissions.go b/modules/authority/services/local/code/pkg/business/state/permissions.go index 41eb55e7..b5d88c84 100644 --- a/modules/authority/services/local/code/pkg/business/state/permissions.go +++ b/modules/authority/services/local/code/pkg/business/state/permissions.go @@ -96,7 +96,9 @@ func (s *State) PermissionReport() []string { roles := map[string][]*basev0.WorkScopeV1{} if m.Kind == "solution" { for name, scopes := range m.Roles { - roles["installation/"+name] = scopes + // Statically an installation-owned scope covers its kind; which + // ids is each installation's, decided at issue. + roles["installation/"+name] = Normalize(append(slices.Clone(scopes), m.OwnedRoles[name]...)) } } else { for key, role := range s.roles { diff --git a/modules/authority/services/local/code/pkg/business/state/solution_roles.go b/modules/authority/services/local/code/pkg/business/state/solution_roles.go index 9dea510c..6274c79a 100644 --- a/modules/authority/services/local/code/pkg/business/state/solution_roles.go +++ b/modules/authority/services/local/code/pkg/business/state/solution_roles.go @@ -2,8 +2,11 @@ package state import ( "fmt" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "maps" "slices" + "strings" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) // PersonSolutionScopes returns the person's full effective installation roles, @@ -35,14 +38,61 @@ func (s *State) personSolutionScopes(p Person, m Manifest) []*basev0.WorkScopeV1 continue } if inst.Owner == p.ID { - held = append(held, m.Roles[InstallerRole]...) + held = append(held, roleGrants(m, InstallerRole, inst)...) } for _, team := range inst.Teams { if slices.Contains(p.Teams, team) { - held = append(held, m.Roles[inst.Role]...) + held = append(held, roleGrants(m, inst.Role, inst)...) break } } } return Normalize(held) } + +// roleGrants is what role grants on one installation of m: its declared +// scopes, and each installation-owned scope limited to the ids inst owns of +// that kind. A kind it owns nothing of grants nothing: an empty id list would +// read as the whole kind, so such a scope is dropped, never emitted. +func roleGrants(m Manifest, role string, inst Installation) []*basev0.WorkScopeV1 { + out := slices.Clone(m.Roles[role]) + for _, scope := range m.OwnedRoles[role] { + if ids := inst.Owned[scope.GetResourceKind()]; len(ids) > 0 { + out = append(out, &basev0.WorkScopeV1{ResourceKind: scope.GetResourceKind(), Actions: slices.Clone(scope.GetActions()), ResourceIds: slices.Clone(ids)}) + } + } + return out +} + +// AttachResource records that id of kind was created or attached through one +// installation. candidates are the installations the presenting capability's +// verified provenance names; exactly one active installation at tenant must +// remain. Attaching only widens what an installation-owned scope covers, so no +// revision moves: every capability already issued names its own ids. +func (s *State) AttachResource(tenant string, candidates []string, kind, id string) (string, error) { + candidates = slices.Compact(slices.Sorted(slices.Values(candidates))) + switch len(candidates) { + case 0: + return "", fmt.Errorf("%w: the capability was issued through no installation, so none owns %s %q", ErrForbidden, kind, id) + case 1: + default: + return "", fmt.Errorf("%w: the capability was issued through %d installations (%s); an attachment has exactly one owner", ErrForbidden, len(candidates), strings.Join(candidates, ", ")) + } + s.mu.Lock() + defer s.mu.Unlock() + inst, ok := s.installations[candidates[0]] + if !ok || inst.Organization != tenant || inst.Status != Active { + return "", fmt.Errorf("%w: installation %s is not active at %s, so it owns nothing new", ErrForbidden, candidates[0], tenant) + } + if slices.Contains(inst.Owned[kind], id) { + return inst.ID, nil + } + owned := maps.Clone(inst.Owned) + if owned == nil { + owned = map[string][]string{} + } + owned[kind] = slices.Sorted(slices.Values(append(slices.Clone(inst.Owned[kind]), id))) + inst.Owned = owned + s.installations[inst.ID] = inst + return inst.ID, nil +} diff --git a/modules/authority/services/local/code/pkg/business/state/state.go b/modules/authority/services/local/code/pkg/business/state/state.go index dfefef91..facd17cb 100644 --- a/modules/authority/services/local/code/pkg/business/state/state.go +++ b/modules/authority/services/local/code/pkg/business/state/state.go @@ -154,6 +154,10 @@ type Manifest struct { // InstallerRole; a module declares none, since a module is installed by // composition and a person's access to it is the organisation's roles. Roles map[string][]*basev0.WorkScopeV1 + // OwnedRoles are, per declared role, the scopes it grants only on the + // resources an installation owns (Installation.Owned): each a kind and + // actions naming no ids, resolved per installation by roleGrants. + OwnedRoles map[string][]*basev0.WorkScopeV1 // Methods are what each method the module serves declares a call must hold, // from the authority's signed document delivered with the manifest. Nil // means the delivery declared no method policy: every check naming one of @@ -318,6 +322,10 @@ type Installation struct { Teams []string // Owner installed it and holds the target's installer role on it. Owner string + // Owned is what was created or attached through the installation, kind -> + // sorted ids (AttachResource). Copied on write, never mutated in place, so + // a copy read outside the lock stays consistent; a reinstall starts empty. + Owned map[string][]string } // installationNamespace names installations: InstallationID's UUIDs are @@ -858,7 +866,9 @@ func (s *State) invalidateNarrowedAuthority(next Manifest) { } rolesNarrowed := previous.Kind == "solution" && (!Attenuates(previous.Roles[inst.Role], next.Roles[inst.Role]) || - !Attenuates(previous.Roles[InstallerRole], next.Roles[InstallerRole])) + !Attenuates(previous.Roles[InstallerRole], next.Roles[InstallerRole]) || + !Attenuates(previous.OwnedRoles[inst.Role], next.OwnedRoles[inst.Role]) || + !Attenuates(previous.OwnedRoles[InstallerRole], next.OwnedRoles[InstallerRole])) if !bindingsNarrowed && !rolesNarrowed { continue } @@ -1115,15 +1125,14 @@ func (s *State) InstallationScopes(id, audience string) (Installation, []*basev0 if !ok || target.Kind != "solution" { return Installation{}, nil, 0, fmt.Errorf("%w: installation target is not a delivered solution", ErrUnknown) } - held, ok := target.Roles[inst.Role] - if !ok { + if _, ok := target.Roles[inst.Role]; !ok { return Installation{}, nil, 0, fmt.Errorf("%w: role %q is no longer declared by %s", ErrUnknown, inst.Role, inst.Target) } served, err := s.moduleByPrincipal(audience) if err != nil { return Installation{}, nil, 0, err } - return inst, Intersect(held, served.ResourceKinds), s.revisions[inst.Organization] + 1, nil + return inst, Intersect(roleGrants(target, inst.Role, inst), served.ResourceKinds), s.revisions[inst.Organization] + 1, nil } // List answers what one organisation has installed, sorted by id. @@ -1499,7 +1508,7 @@ func sameScopes(a, b []*basev0.WorkScopeV1) bool { // workload and cannot supply a role grant. A module declares no roles. func validateRoles(m Manifest) error { if m.Kind != "solution" { - if len(m.Roles) > 0 { + if len(m.Roles) > 0 || len(m.OwnedRoles) > 0 { return fmt.Errorf("%w: module %q declares roles; a module's access is the organisation's roles", ErrForbidden, m.Module) } return nil @@ -1512,11 +1521,22 @@ func validateRoles(m Manifest) error { grantable = append(grantable, binding.InvokeScopes...) grantable = append(grantable, binding.LookupScopes...) } + for name, owned := range m.OwnedRoles { + if _, declared := m.Roles[name]; !declared { + return fmt.Errorf("%w: %s declares installation-owned scopes for undeclared role %q", ErrForbidden, m.Module, name) + } + for _, scope := range owned { + if len(scope.GetResourceIds()) > 0 { + return fmt.Errorf("%w: role %q of %s names resource ids on an installation-owned scope; its ids are each installation's own", ErrForbidden, name, m.Module) + } + } + } for name, scopes := range m.Roles { - if len(scopes) == 0 { + owned := m.OwnedRoles[name] + if len(scopes)+len(owned) == 0 { return fmt.Errorf("%w: role %q of %s grants nothing", ErrForbidden, name, m.Module) } - if !Attenuates(scopes, grantable) { + if !Attenuates(scopes, grantable) || !Attenuates(owned, grantable) { return fmt.Errorf("%w: role %q of %s grants beyond what %s serves or explicitly consumes through invoke/lookup bindings", ErrForbidden, name, m.Module, m.Module) } } diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index 9068203b..d74c8802 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -266,7 +266,7 @@ func New(t *testing.T) *Kit { // manifests from their own workloads. // robin and assistant are declared for the artifact tests, which compose // them themselves. - for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation"} { + for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "conformance-store", "conformance-owner-a", "conformance-owner-b"} { launcher.Declare(Workload(module)) } _, signing, err := ed25519.GenerateKey(rand.Reader) diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go index 035f4404..d1bf36f7 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go @@ -1217,6 +1217,125 @@ func (x *ListResourcesResponse) GetGrants() []*ResourceGrant { return nil } +// AttachResourceRequest names one resource and the capability it was created under. +type AttachResourceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The capability the creating call carried, addressed to the caller. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // The module serving resource_kind, proving which module it is. + Actor *WorkloadCredential `protobuf:"bytes,2,opt,name=actor,proto3" json:"actor,omitempty"` + // The resource kind, as the caller serves it. + ResourceKind string `protobuf:"bytes,3,opt,name=resource_kind,json=resourceKind,proto3" json:"resource_kind,omitempty"` + // The resource id, as the caller knows it. + ResourceId string `protobuf:"bytes,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachResourceRequest) Reset() { + *x = AttachResourceRequest{} + mi := &file_host_authority_v1_authority_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachResourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachResourceRequest) ProtoMessage() {} + +func (x *AttachResourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachResourceRequest.ProtoReflect.Descriptor instead. +func (*AttachResourceRequest) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} +} + +func (x *AttachResourceRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *AttachResourceRequest) GetActor() *WorkloadCredential { + if x != nil { + return x.Actor + } + return nil +} + +func (x *AttachResourceRequest) GetResourceKind() string { + if x != nil { + return x.ResourceKind + } + return "" +} + +func (x *AttachResourceRequest) GetResourceId() string { + if x != nil { + return x.ResourceId + } + return "" +} + +// AttachResourceResponse names the installation that now owns the resource. +type AttachResourceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The owning installation, read from the capability's provenance. + InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachResourceResponse) Reset() { + *x = AttachResourceResponse{} + mi := &file_host_authority_v1_authority_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachResourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachResourceResponse) ProtoMessage() {} + +func (x *AttachResourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AttachResourceResponse.ProtoReflect.Descriptor instead. +func (*AttachResourceResponse) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} +} + +func (x *AttachResourceResponse) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + // ResourceGrant is one way of naming covered resources. Exactly one is set. type ResourceGrant struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1233,7 +1352,7 @@ type ResourceGrant struct { func (x *ResourceGrant) Reset() { *x = ResourceGrant{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1245,7 +1364,7 @@ func (x *ResourceGrant) String() string { func (*ResourceGrant) ProtoMessage() {} func (x *ResourceGrant) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1258,7 +1377,7 @@ func (x *ResourceGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceGrant.ProtoReflect.Descriptor instead. func (*ResourceGrant) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1420,18 +1539,27 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\rresource_kind\x18\x03 \x01(\tR\fresourceKind\x12\x16\n" + "\x06action\x18\x04 \x01(\tR\x06action\"Q\n" + "\x15ListResourcesResponse\x128\n" + - "\x06grants\x18\x01 \x03(\v2 .host.authority.v1.ResourceGrantR\x06grants\"t\n" + + "\x06grants\x18\x01 \x03(\v2 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n" + + "\x15AttachResourceRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12;\n" + + "\x05actor\x18\x02 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12#\n" + + "\rresource_kind\x18\x03 \x01(\tR\fresourceKind\x12\x1f\n" + + "\vresource_id\x18\x04 \x01(\tR\n" + + "resourceId\"A\n" + + "\x16AttachResourceResponse\x12'\n" + + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"t\n" + "\rResourceGrant\x12\x12\n" + "\x03all\x18\x01 \x01(\bH\x00R\x03all\x12\x10\n" + "\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apatternB\a\n" + - "\x05match2\xac\x05\n" + + "\x05match2\x93\x06\n" + "\x10AuthorityService\x12j\n" + "\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:issue\x12j\n" + "\x05Check\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:check\x12G\n" + "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12b\n" + - "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12M\n" + + "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12e\n" + + "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12M\n" + "\bDelegate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n" + "\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n" + "\rGetDelegation\x12'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3" @@ -1448,7 +1576,7 @@ func file_host_authority_v1_authority_proto_rawDescGZIP() []byte { return file_host_authority_v1_authority_proto_rawDescData } -var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_host_authority_v1_authority_proto_goTypes = []any{ (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef @@ -1468,44 +1596,49 @@ var file_host_authority_v1_authority_proto_goTypes = []any{ (*PublicKey)(nil), // 15: host.authority.v1.PublicKey (*ListResourcesRequest)(nil), // 16: host.authority.v1.ListResourcesRequest (*ListResourcesResponse)(nil), // 17: host.authority.v1.ListResourcesResponse - (*ResourceGrant)(nil), // 18: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 19: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 20: host.authority.v1.WorkContextV1 + (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse + (*ResourceGrant)(nil), // 20: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 21: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 22: host.authority.v1.WorkContextV1 } var file_host_authority_v1_authority_proto_depIdxs = []int32{ 7, // 0: host.authority.v1.IssueRequest.person:type_name -> host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 19, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 21, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 19, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 19, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 21, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 21, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 20, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 20, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 22, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 22, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 18, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant - 0, // 14: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 15: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 16: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 17: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 2, // 18: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 19: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 20: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 21: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 22: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 23: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 24: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 3, // 25: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 26: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 27: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 21, // [21:28] is the sub-list for method output_type - 14, // [14:21] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 20, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 15: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 16: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 17: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 18: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 19: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 2, // 20: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 21: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 22: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 23: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 24: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 25: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 26: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 27: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 3, // 28: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 29: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 30: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 23, // [23:31] is the sub-list for method output_type + 15, // [15:23] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1521,7 +1654,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[18].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[20].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1533,7 +1666,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 19, + NumMessages: 21, NumExtensions: 0, NumServices: 1, }, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index 51d772ee..c9dc897a 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -30,6 +30,7 @@ const ( AuthorityService_Check_FullMethodName = "/host.authority.v1.AuthorityService/Check" AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" + AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" @@ -57,6 +58,18 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) @@ -117,6 +130,16 @@ func (c *authorityServiceClient) ListResources(ctx context.Context, in *ListReso return out, nil } +func (c *authorityServiceClient) AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AttachResourceResponse) + err := c.cc.Invoke(ctx, AuthorityService_AttachResource_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Delegation) @@ -169,6 +192,18 @@ type AuthorityServiceServer interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *DelegateRequest) (*Delegation, error) @@ -201,6 +236,9 @@ func (UnimplementedAuthorityServiceServer) Keys(context.Context, *KeysRequest) ( func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") } +func (UnimplementedAuthorityServiceServer) AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AttachResource not implemented") +} func (UnimplementedAuthorityServiceServer) Delegate(context.Context, *DelegateRequest) (*Delegation, error) { return nil, status.Error(codes.Unimplemented, "method Delegate not implemented") } @@ -303,6 +341,24 @@ func _AuthorityService_ListResources_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _AuthorityService_AttachResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AttachResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).AttachResource(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_AttachResource_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).AttachResource(ctx, req.(*AttachResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_Delegate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DelegateRequest) if err := dec(in); err != nil { @@ -380,6 +436,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, }, + { + MethodName: "AttachResource", + Handler: _AuthorityService_AttachResource_Handler, + }, { MethodName: "Delegate", Handler: _AuthorityService_Delegate_Handler, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index 497d1a03..36dded0c 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -48,6 +48,9 @@ const ( // AuthorityServiceListResourcesProcedure is the fully-qualified name of the AuthorityService's // ListResources RPC. AuthorityServiceListResourcesProcedure = "/host.authority.v1.AuthorityService/ListResources" + // AuthorityServiceAttachResourceProcedure is the fully-qualified name of the AuthorityService's + // AttachResource RPC. + AuthorityServiceAttachResourceProcedure = "/host.authority.v1.AuthorityService/AttachResource" // AuthorityServiceDelegateProcedure is the fully-qualified name of the AuthorityService's Delegate // RPC. AuthorityServiceDelegateProcedure = "/host.authority.v1.AuthorityService/Delegate" @@ -77,6 +80,18 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) @@ -124,6 +139,12 @@ func NewAuthorityServiceClient(httpClient connect.HTTPClient, baseURL string, op connect.WithSchema(authorityServiceMethods.ByName("ListResources")), connect.WithClientOptions(opts...), ), + attachResource: connect.NewClient[v1.AttachResourceRequest, v1.AttachResourceResponse]( + httpClient, + baseURL+AuthorityServiceAttachResourceProcedure, + connect.WithSchema(authorityServiceMethods.ByName("AttachResource")), + connect.WithClientOptions(opts...), + ), delegate: connect.NewClient[v1.DelegateRequest, v1.Delegation]( httpClient, baseURL+AuthorityServiceDelegateProcedure, @@ -151,6 +172,7 @@ type authorityServiceClient struct { check *connect.Client[v1.CheckRequest, v1.CheckResponse] keys *connect.Client[v1.KeysRequest, v1.KeysResponse] listResources *connect.Client[v1.ListResourcesRequest, v1.ListResourcesResponse] + attachResource *connect.Client[v1.AttachResourceRequest, v1.AttachResourceResponse] delegate *connect.Client[v1.DelegateRequest, v1.Delegation] revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] getDelegation *connect.Client[v1.GetDelegationRequest, v1.Delegation] @@ -176,6 +198,11 @@ func (c *authorityServiceClient) ListResources(ctx context.Context, req *connect return c.listResources.CallUnary(ctx, req) } +// AttachResource calls host.authority.v1.AuthorityService.AttachResource. +func (c *authorityServiceClient) AttachResource(ctx context.Context, req *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) { + return c.attachResource.CallUnary(ctx, req) +} + // Delegate calls host.authority.v1.AuthorityService.Delegate. func (c *authorityServiceClient) Delegate(ctx context.Context, req *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) { return c.delegate.CallUnary(ctx, req) @@ -209,6 +236,18 @@ type AuthorityServiceHandler interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) @@ -252,6 +291,12 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han connect.WithSchema(authorityServiceMethods.ByName("ListResources")), connect.WithHandlerOptions(opts...), ) + authorityServiceAttachResourceHandler := connect.NewUnaryHandler( + AuthorityServiceAttachResourceProcedure, + svc.AttachResource, + connect.WithSchema(authorityServiceMethods.ByName("AttachResource")), + connect.WithHandlerOptions(opts...), + ) authorityServiceDelegateHandler := connect.NewUnaryHandler( AuthorityServiceDelegateProcedure, svc.Delegate, @@ -280,6 +325,8 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han authorityServiceKeysHandler.ServeHTTP(w, r) case AuthorityServiceListResourcesProcedure: authorityServiceListResourcesHandler.ServeHTTP(w, r) + case AuthorityServiceAttachResourceProcedure: + authorityServiceAttachResourceHandler.ServeHTTP(w, r) case AuthorityServiceDelegateProcedure: authorityServiceDelegateHandler.ServeHTTP(w, r) case AuthorityServiceRevokeDelegationProcedure: @@ -311,6 +358,10 @@ func (UnimplementedAuthorityServiceHandler) ListResources(context.Context, *conn return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.ListResources is not implemented")) } +func (UnimplementedAuthorityServiceHandler) AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.AttachResource is not implemented")) +} + func (UnimplementedAuthorityServiceHandler) Delegate(context.Context, *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.Delegate is not implemented")) } diff --git a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go index 31bc6961..3a7cd50d 100644 --- a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go @@ -512,11 +512,19 @@ func (x *ModuleManifestV1) GetRoles() []*RoleV1 { // RoleV1 is one role a solution declares. type RoleV1 struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` + // Scopes the role grants only on the resources the installation owns: those + // created or attached through it (AuthorityService.AttachResource). Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + InstallationOwnedScopes []*v1.WorkScopeV1 `protobuf:"bytes,3,rep,name=installation_owned_scopes,json=installationOwnedScopes,proto3" json:"installation_owned_scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoleV1) Reset() { @@ -563,6 +571,13 @@ func (x *RoleV1) GetScopes() []*v1.WorkScopeV1 { return nil } +func (x *RoleV1) GetInstallationOwnedScopes() []*v1.WorkScopeV1 { + if x != nil { + return x.InstallationOwnedScopes + } + return nil +} + // BindingV1 is one declared call towards another module. type BindingV1 struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -802,10 +817,11 @@ const file_host_composition_v1_composition_proto_rawDesc = "" + "\bbindings\x18\x05 \x03(\v2\x1e.host.composition.v1.BindingV1R\bbindings\x12J\n" + "\x0eresource_kinds\x18\x06 \x03(\v2#.host.composition.v1.ResourceKindV1R\rresourceKinds\x12/\n" + "\x04page\x18\a \x01(\v2\x1b.host.composition.v1.PageV1R\x04page\x121\n" + - "\x05roles\x18\b \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"T\n" + + "\x05roles\x18\b \x03(\v2\x1b.host.composition.v1.RoleV1R\x05roles\"\xb0\x01\n" + "\x06RoleV1\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + - "\x06scopes\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x06scopes\"\x8a\x02\n" + + "\x06scopes\x18\x02 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12Z\n" + + "\x19installation_owned_scopes\x18\x03 \x03(\v2\x1e.host.authority.v1.WorkScopeV1R\x17installationOwnedScopes\"\x8a\x02\n" + "\tBindingV1\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12C\n" + @@ -860,16 +876,17 @@ var file_host_composition_v1_composition_proto_depIdxs = []int32{ 9, // 9: host.composition.v1.ModuleManifestV1.page:type_name -> host.composition.v1.PageV1 6, // 10: host.composition.v1.ModuleManifestV1.roles:type_name -> host.composition.v1.RoleV1 10, // 11: host.composition.v1.RoleV1.scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 12: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 13: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 - 10, // 14: host.composition.v1.BindingV1.lookup_scopes:type_name -> host.authority.v1.WorkScopeV1 - 0, // 15: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest - 4, // 16: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse - 16, // [16:17] is the sub-list for method output_type - 15, // [15:16] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 10, // 12: host.composition.v1.RoleV1.installation_owned_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 13: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 14: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 + 10, // 15: host.composition.v1.BindingV1.lookup_scopes:type_name -> host.authority.v1.WorkScopeV1 + 0, // 16: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest + 4, // 17: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse + 17, // [17:18] is the sub-list for method output_type + 16, // [16:17] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_host_composition_v1_composition_proto_init() } diff --git a/modules/authority/services/local/code/plugins/authority/plugin.go b/modules/authority/services/local/code/plugins/authority/plugin.go index 1c171345..1cde4d55 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -184,6 +184,16 @@ func (p *Plugin) ListResources(ctx context.Context, req *authorityv1.ListResourc return out, nil } +// AttachResource records a resource created through the installation the +// presented capability was issued through. +func (p *Plugin) AttachResource(ctx context.Context, req *authorityv1.AttachResourceRequest) (*authorityv1.AttachResourceResponse, error) { + id, err := p.Issuer.AttachResource(ctx, req.GetToken(), req.GetActor().GetCredential(), req.GetResourceKind(), req.GetResourceId()) + if err != nil { + return nil, Status(err) + } + return &authorityv1.AttachResourceResponse{InstallationId: id}, nil +} + // Keys returns the issuer and its keys. func (p *Plugin) Keys(context.Context, *authorityv1.KeysRequest) (*authorityv1.KeysResponse, error) { return p.Issuer.Keys(), nil diff --git a/modules/authority/services/local/code/plugins/composition/plugin.go b/modules/authority/services/local/code/plugins/composition/plugin.go index 0e60223a..1d0290b4 100644 --- a/modules/authority/services/local/code/plugins/composition/plugin.go +++ b/modules/authority/services/local/code/plugins/composition/plugin.go @@ -89,7 +89,7 @@ func (p *Plugin) ApplyManifest(ctx context.Context, req *compositionv1.ApplyMani if m, err = ceiled(m, issuer.FromWire(doc.GetScopeCeilings())); err != nil { return nil, err } - if m.Roles, err = rolesOf(doc); err != nil { + if m.Roles, m.OwnedRoles, err = rolesOf(doc.GetRoles()); err != nil { return nil, err } } @@ -127,9 +127,12 @@ func Manifest(in *compositionv1.ModuleManifestV1) state.Manifest { } for _, r := range in.GetRoles() { if m.Roles == nil { - m.Roles = map[string][]*basev0.WorkScopeV1{} + m.Roles, m.OwnedRoles = map[string][]*basev0.WorkScopeV1{}, map[string][]*basev0.WorkScopeV1{} } m.Roles[r.GetName()] = state.Normalize(issuer.FromWire(r.GetScopes())) + if owned := r.GetInstallationOwnedScopes(); len(owned) > 0 { + m.OwnedRoles[r.GetName()] = issuer.FromWire(owned) + } } return m } @@ -198,18 +201,23 @@ func methodsOf(doc *compositionv1.AuthorityManifestV1) (map[string]state.MethodP // within what it serves or explicitly consumes through invoke/lookup bindings, // and a module declares none, so a // module's delivery listing any is refused there by name. -func rolesOf(doc *compositionv1.AuthorityManifestV1) (map[string][]*basev0.WorkScopeV1, error) { - if len(doc.GetRoles()) == 0 { - return nil, nil +// Installation-owned scopes are kept as declared, ids and all, so the state +// refuses one naming ids by name rather than a merge hiding it. +func rolesOf(declared []*compositionv1.RoleV1) (roles, owned map[string][]*basev0.WorkScopeV1, err error) { + if len(declared) == 0 { + return nil, nil, nil } - roles := make(map[string][]*basev0.WorkScopeV1, len(doc.GetRoles())) - for _, role := range doc.GetRoles() { + roles, owned = make(map[string][]*basev0.WorkScopeV1, len(declared)), map[string][]*basev0.WorkScopeV1{} + for _, role := range declared { if _, twice := roles[role.GetName()]; twice || role.GetName() == "" { - return nil, status.Errorf(codes.InvalidArgument, "the authority's document lists role %q twice or unnamed", role.GetName()) + return nil, nil, status.Errorf(codes.InvalidArgument, "the authority's document lists role %q twice or unnamed", role.GetName()) } roles[role.GetName()] = state.Normalize(issuer.FromWire(role.GetScopes())) + if scopes := role.GetInstallationOwnedScopes(); len(scopes) > 0 { + owned[role.GetName()] = issuer.FromWire(scopes) + } } - return roles, nil + return roles, owned, nil } // ceiled gives each of the module's own kinds the actions its contract declares diff --git a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go index 75ecc7e3..5c5d56aa 100644 --- a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go @@ -25,6 +25,7 @@ import ( conformance "github.com/codefly-dev/authority/libraries/conformance/go" "gateway/pkg/business/state" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) func TestTheLocalAuthorityPassesTheSuiteEveryImplementationMustPass(t *testing.T) { @@ -118,7 +119,33 @@ people: t.Fatal(err) } } + // Two installed solutions for installation-owned role scopes: Alex installed + // both, and Maya's finance team holds A's viewer role and nothing of B's. + owned := map[string]string{} + for _, solution := range []string{"conformance-owner-a", "conformance-owner-b"} { + note := testkit.Scope("conformance.owned-note", "read") + m := state.Manifest{Module: solution, Principal: solution, Kind: "solution", Generation: 1, ResourceKinds: []*basev0.WorkScopeV1{note}, + Roles: map[string][]*basev0.WorkScopeV1{state.InstallerRole: {note}, "viewer": {note}}} + if _, err := k.State.ApplyManifest(m); err != nil { + t.Fatal(err) + } + inst, err := k.State.Install(context.Background(), "acme", solution, "viewer", "alex") + if err != nil { + t.Fatal(err) + } + owned[solution] = inst.ID + } + if _, err := k.State.GrantToTeam(context.Background(), "acme", owned["conformance-owner-a"], "finance"); err != nil { + t.Fatal(err) + } conformance.Run(t, conn, descriptors, conformance.Fixture{ + InstallationResources: &conformance.InstallationResourcesCase{ + Owner: "fixture-alex", Viewer: "fixture-maya", Generation: 2, + A: conformance.OwnedInstallation{Solution: "conformance-owner-a", Credential: k.Credential(t, "conformance-owner-a"), Installation: owned["conformance-owner-a"]}, + B: conformance.OwnedInstallation{Solution: "conformance-owner-b", Credential: k.Credential(t, "conformance-owner-b"), Installation: owned["conformance-owner-b"]}, + Store: "conformance-store", StoreCredential: k.Credential(t, "conformance-store"), Kind: "conformance.collection", + ResourceA: "collection-a", ResourceA2: "collection-a2", ResourceB: "collection-b", + }, WorkloadOrganizations: &conformance.WorkloadOrganizationsCase{ Ambiguous: conformance.WorkloadOrganizationCase{Credential: k.Credential(t, "org-directory"), Module: "org-directory", Binding: "records", Audience: "records"}, None: conformance.WorkloadOrganizationCase{Credential: k.Credential(t, "runtime"), Module: "runtime", Binding: "model", Audience: "model"}, diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index ce8a6c75..3c1e50d1 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -33,6 +33,18 @@ service AuthorityService { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse); + // AttachResource records that one resource was created or attached through + // an installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the module that + // serves the kind, proving itself with its workload credential, and presents + // the capability the creating call carried, addressed to it and holding an + // action on the kind. The owning installation is read from that capability's + // verified provenance and never named by the caller: the installation itself + // when it is the owner, otherwise the one solution in its actor chain. A + // capability issued through no installation, or through two, is refused. + // Attaching only widens what an installation-owned scope covers, so it moves + // no authorization revision; a reinstalled installation owns nothing. + rpc AttachResource(AttachResourceRequest) returns (AttachResourceResponse); // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. rpc Delegate(DelegateRequest) returns (Delegation); @@ -244,6 +256,24 @@ message ListResourcesResponse { repeated ResourceGrant grants = 1; } +// AttachResourceRequest names one resource and the capability it was created under. +message AttachResourceRequest { + // The capability the creating call carried, addressed to the caller. + string token = 1; + // The module serving resource_kind, proving which module it is. + WorkloadCredential actor = 2; + // The resource kind, as the caller serves it. + string resource_kind = 3; + // The resource id, as the caller knows it. + string resource_id = 4; +} + +// AttachResourceResponse names the installation that now owns the resource. +message AttachResourceResponse { + // The owning installation, read from the capability's provenance. + string installation_id = 1; +} + // ResourceGrant is one way of naming covered resources. Exactly one is set. message ResourceGrant { oneof match { diff --git a/modules/authority/services/local/proto/host/composition/v1/composition.proto b/modules/authority/services/local/proto/host/composition/v1/composition.proto index a5085936..1d7441b5 100644 --- a/modules/authority/services/local/proto/host/composition/v1/composition.proto +++ b/modules/authority/services/local/proto/host/composition/v1/composition.proto @@ -121,6 +121,14 @@ message ModuleManifestV1 { message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; + // Scopes the role grants only on the resources the installation owns: those + // created or attached through it (AuthorityService.AttachResource). Each + // names a kind and actions and no resource ids, since ids are per + // installation, and is held to the same ceiling as scopes. The authority + // resolves each, for the installation the role is held on, into the same + // scope limited to the ids that installation owns of the kind; a kind it owns + // nothing of grants nothing, and never the whole kind. + repeated host.authority.v1.WorkScopeV1 installation_owned_scopes = 3; } // BindingV1 is one declared call towards another module. diff --git a/modules/console/services/api/service.codefly.yaml b/modules/console/services/api/service.codefly.yaml index 8b7968f4..60a1f046 100644 --- a/modules/console/services/api/service.codefly.yaml +++ b/modules/console/services/api/service.codefly.yaml @@ -32,7 +32,7 @@ spec: # The console reaches the authority through the interface it depends on, never a # service: the composition resolves which implementation answers. service-dependencies: - - interface: codefly.dev/authority@0.2.0 + - interface: codefly.dev/authority@0.3.0 # The console acts as a workload of its own: the run vouches for it and delivers # its manifest to the authority. module-identity: true From 992b11378c82eb63fba9bf4c80c47d8fc99e0ce0 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:57:13 -0400 Subject: [PATCH 2/4] fix: attach only what an installation creates, fenced and bounded (review r47) B1: AttachResource now requires create on the whole kind (a read-only or id-limited capability attaches nothing) and refuses an id another installation of the tenant owns: attaching is for new resources, never for existing ones. Both reviewer probes are conformance refusals. M1: the capability's authorization revision is compared with the tenant's under the state write lock, so a pre-uninstall capability never attaches into the reinstalled installation. M2: edge, hop and console require authority >=0.2.0 <0.4.0; they call nothing new and resolve against a host still at 0.2.0. M3: an installation owns at most 32 ids of one kind, each at most 128 bytes, refused by name past either; capabilities stay bounded. m4: library.codefly.yaml keeps the python entry and the 0.0.17 companion until cli#1163 lets Python regenerate. m5: only a kind's one serving module attaches; a kind two modules declare attaches nothing. m6: README versions; an installed solution serving its own kind attaches through a person's capability addressed to it. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 377041 -> 377528 bytes .../host/authority/v1/authority_grpc.pb.go | 54 ++++++++++------- .../gen/host/composition/v1/composition.pb.go | 2 +- .../authority-client/library.codefly.yaml | 8 ++- .../src/gen/host/authority/v1/authority_pb.ts | 27 +++++---- .../gen/host/composition/v1/composition_pb.ts | 2 +- libraries/conformance/go/README.md | 2 +- .../conformance/go/installation_resources.go | 55 +++++++++++++----- .../services/edge/service.codefly.yaml | 2 +- .../services/hop/service.codefly.yaml | 2 +- .../code/pkg/business/issuer/attach_test.go | 29 +++++++++ .../pkg/business/issuer/solution_roles.go | 46 ++++++++++----- .../business/state/installation_owned_test.go | 53 ++++++++++++++--- .../code/pkg/business/state/solution_roles.go | 37 ++++++++++-- .../local/code/pkg/business/state/state.go | 14 +++++ .../host/authority/v1/authority_grpc.pb.go | 54 ++++++++++------- .../authorityv1connect/authority.connect.go | 54 ++++++++++------- .../gen/host/composition/v1/composition.pb.go | 2 +- .../registry_test/local_authority_test.go | 2 +- .../proto/host/authority/v1/authority.proto | 27 +++++---- .../host/composition/v1/composition.proto | 2 +- .../console/services/api/service.codefly.yaml | 2 +- 24 files changed, 355 insertions(+), 136 deletions(-) create mode 100644 modules/authority/services/local/code/pkg/business/issuer/attach_test.go diff --git a/README.md b/README.md index 27dbf364..96260266 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ staging validated. **Rung 1 first; it is the one that matters (owner, 2026-10-21).** The loop: 1. Cut a branch in module-saas-starter. accounts declares in its module - interface that it implements `codefly.dev/authority@0.2.0`, `codefly.dev/composition@0.1.0`, + interface that it implements `codefly.dev/authority@0.3.0`, `codefly.dev/composition@0.2.0`, `codefly.dev/installation@0.1.0` and `codefly.dev/session@0.2.0`, takes this repo's protos (bindings via `codefly generate proto`), and serves the four services as adapters over what it already has: `Issue` → StartTask / StartInstallationTask / @@ -271,7 +271,7 @@ Gap 7 was a decision, taken on 2026-10-22: the interface carries the host's name ## The namespace, settled (2026-10-22) The interfaces are the host's contract, so they carry the host's names: publisher -`codefly.dev` (ids `codefly.dev/authority@0.2.0`, `composition`, `installation`, +`codefly.dev` (ids `codefly.dev/authority@0.3.0`, `composition`, `installation`, `installable`, `session`) and protobuf packages `host.authority.v1`, `host.composition.v1`, `host.installation.v1`, `host.session.v1` under `proto/host/`. Not `codefly.*`: the host's buf configuration excludes a `codefly/` proto directory @@ -941,3 +941,12 @@ under the existing All consumers and implementations must select the updated interface versions; this repository updates its module, edge, hop and console declarations. SaaS must adopt the version and fields before claiming this contract. + +`codefly.dev/authority@0.3.0` adds `AttachResource` and +`codefly.dev/composition@0.2.0` adds `RoleV1.installation_owned_scopes`: a role +scope held only on the resources its installation created, recorded from the +creating capability's provenance (one owner per resource, at most 32 per kind). +Both are additive. The local authority implements 0.3.0 and 0.2.0; the edge, +hop and console call nothing new, so they require `>=0.2.0 <0.4.0` and resolve +against a host still at 0.2.0. Shared conformance requires +`InstallationResources`, so a host must implement both before it passes. diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index 3e95d385..a164b48a 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:1fc934d90a008264422577981552de8401befcd8f0adcb7603565129b7b2d8b4", + "digest": "sha256:8e18b679051680b657a5e22de7e13bf7ae3301e77adc6736bc259e7234cd3713", "services": [ { "name": "GatewayService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index 133aa5311bd7a9b713e133c052f35b199b4505de..f0ab9ed9ab1d49a2d568664fbc0a2a62054269b0 100644 GIT binary patch delta 4841 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 J?xEMG{~JPuc_#n> diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go index c9dc897a..631f374f 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go @@ -58,17 +58,24 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. @@ -192,17 +199,24 @@ type AuthorityServiceServer interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go b/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go index b98e5f26..faed3ecd 100644 --- a/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go +++ b/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go @@ -516,7 +516,7 @@ type RoleV1 struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` // Scopes the role grants only on the resources the installation owns: those - // created or attached through it (AuthorityService.AttachResource). Each + // newly created through it (AuthorityService.AttachResource). Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index 7b8c28a8..c8490063 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -6,6 +6,10 @@ languages: path: go/ exports: - github.com/codefly-dev/authority/libraries/authority-client/go + - name: python + path: python/ + exports: + - authority_client - name: typescript path: typescript/ exports: @@ -16,10 +20,10 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:1fc934d90a008264422577981552de8401befcd8f0adcb7603565129b7b2d8b4 + contract-digest: sha256:8e18b679051680b657a5e22de7e13bf7ae3301e77adc6736bc259e7234cd3713 services: - GatewayService generated-by: codefly: development - companion: ghcr.io/codefly-dev/proto:0.0.16 + companion: ghcr.io/codefly-dev/proto:0.0.17 facade: false diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts index 2b110536..36dc9ca2 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -817,17 +817,24 @@ export const AuthorityService: GenService<{ output: typeof ListResourcesResponseSchema; }, /** - * AttachResource records that one resource was created or attached through - * an installation, so a role scope its solution declares installation-owned - * (RoleV1.installation_owned_scopes) covers it. The caller is the module that - * serves the kind, proving itself with its workload credential, and presents - * the capability the creating call carried, addressed to it and holding an - * action on the kind. The owning installation is read from that capability's + * AttachResource records that one resource was newly created through an + * installation, so a role scope its solution declares installation-owned + * (RoleV1.installation_owned_scopes) covers it. It is for what the + * installation creates, never for an existing resource: a resource has one + * owner in an organisation, and an id another installation owns is refused. + * The caller is the kind's one serving module (a kind two composed modules + * declare attaches nothing), proving itself with its workload credential, + * and presents the capability the creating call carried: addressed to it, at + * the organisation's current authorization revision, and holding create on + * the whole kind; a capability that only reads, or acts on named ids, + * attaches nothing. The owning installation is read from that capability's * verified provenance and never named by the caller: the installation itself - * when it is the owner, otherwise the one solution in its actor chain. A - * capability issued through no installation, or through two, is refused. - * Attaching only widens what an installation-owned scope covers, so it moves - * no authorization revision; a reinstalled installation owns nothing. + * when it is the owner, the installed solution when it is the caller, and + * each solution in its actor chain; exactly one must result. Owned ids travel + * in every capability holding them, so an installation owns at most 32 of one + * kind, each id at most 128 bytes, refused by name past either. Attaching + * only widens what an owned scope covers, so it moves no authorization + * revision; a reinstalled installation owns nothing. * * @generated from rpc host.authority.v1.AuthorityService.AttachResource */ diff --git a/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts b/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts index d903a4e4..f1670b39 100644 --- a/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts @@ -327,7 +327,7 @@ export type RoleV1 = Message<"host.composition.v1.RoleV1"> & { /** * Scopes the role grants only on the resources the installation owns: those - * created or attached through it (AuthorityService.AttachResource). Each + * newly created through it (AuthorityService.AttachResource). Each * names a kind and actions and no resource ids, since ids are per * installation, and is held to the same ceiling as scopes. The authority * resolves each, for the installation the role is held on, into the same diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 8623e3f8..7356d68c 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -24,7 +24,7 @@ providing different labels does not remove an obligation. | Delivered solution roles at their own audience, binding roots and children; no-role refusals; ceiling enforcement | `SolutionRoles`: a signed wiki viewer role grants Maya documents write, which she lacks independently. Replayed deliveries use deterministic authority-document bytes. | | Rule A: current owner grants intersected with each target binding; canonical `//` identity; named hop refusals | `Chain`, `ChildScopes`, and the individual binding-refusal cases | | Enduring narrowing through two hops | `ChildScopes` and `ParentLimits`: stored delegations retain complete ceilings; parent action/resource limits and entirely dropped kinds remain capped across target changes. Multiple action/resource rows must not become a cross product. | -| Installation-owned role scopes: a role scope limited to what the installation owns, attached from the creating capability's provenance; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens | `InstallationResources`: two installed solutions, A (the viewer's) and B, and a store module serving the kind; the suite delivers all three | +| Installation-owned role scopes: a role scope limited to what the installation created, attached from the creating capability's provenance; a read-only capability attaches nothing and an id another installation owns is refused, by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, and a store module serving the shared kind; the suite delivers all three | | Installation identity, installed caller, explicit organization, pending, active and revoked states | `InstalledSolution`, `InstalledCredential`, `Lifecycle` | | Absent installation versus no entitlement | `FailedPrecondition` with the absent-installation reason versus `PermissionDenied` with the holds-nothing reason | | Inventory access | Admin and owner allowed; ordinary member, anonymous and foreign-organization reads refused | diff --git a/libraries/conformance/go/installation_resources.go b/libraries/conformance/go/installation_resources.go index 4358bab7..bc4a8354 100644 --- a/libraries/conformance/go/installation_resources.go +++ b/libraries/conformance/go/installation_resources.go @@ -14,7 +14,8 @@ import ( // installation-owned. The host seeds two active installations, A and B, of two // solutions at Organization: Owner installed both, and Viewer is entitled to A // alone, through A's viewer role. The suite delivers both solutions and Store, -// the module serving Kind, so their declarations are exactly the suite's. +// the module serving Kind, so their declarations are exactly the suite's. Each +// solution serves its own kind, ".note": a kind has one serving module. type InstallationResourcesCase struct { Owner, Viewer string // fixture tokens A, B OwnedInstallation @@ -31,8 +32,6 @@ type OwnedInstallation struct { Solution, Credential, Installation string } -const ownedOwnKind = "conformance.owned-note" - func (c *client) installationResources(t *testing.T, ctx context.Context, fixture Fixture) { f := fixture.InstallationResources if f == nil || fixture.SignManifest == nil || fixture.SignAuthority == nil || f.Owner == "" || f.Viewer == "" || f.A.Installation == "" || f.B.Installation == "" || @@ -42,24 +41,25 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur const binding, self = "store", "self" create, read := scopeAsk(f.Kind, []string{"create"}, nil), scopeAsk(f.Kind, []string{"read"}, nil) // Store serves Kind; its self binding lets it hop on, and work headless. + both := []string{f.Kind + ":create", f.Kind + ":read"} if _, err := c.deliver(ctx, fixture, f.Store, "module", f.Generation, []string{f.Kind}, map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, []string{"create", "read"}, nil)}}, 0, - solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":read"}, "headless": {f.Kind + ":read"}}}); err != nil { + solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": both, "headless": both}}); err != nil { t.Fatalf("deliver %s: %v", f.Store, err) } // Each installer may create anywhere; each role reads only what its own // installation owns. - own := scopeAsk(ownedOwnKind, []string{"read"}, nil) - doc := map[string]any{ - "scope_ceilings": []any{own}, - "roles": []any{ - map[string]any{"name": "installer", "scopes": []any{own, create}, "installation_owned_scopes": []any{read}}, - map[string]any{"name": "viewer", "scopes": []any{own}, "installation_owned_scopes": []any{read}}, - }, - } for _, s := range []OwnedInstallation{f.A, f.B} { - if _, err := c.deliver(ctx, fixture, s.Solution, "solution", f.Generation, []string{ownedOwnKind}, doc, 0, - solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": {f.Kind + ":create", f.Kind + ":read"}}}); err != nil { + own := scopeAsk(s.Solution+".note", []string{"read"}, nil) + doc := map[string]any{ + "scope_ceilings": []any{own}, + "roles": []any{ + map[string]any{"name": "installer", "scopes": []any{own, create}, "installation_owned_scopes": []any{read}}, + map[string]any{"name": "viewer", "scopes": []any{own}, "installation_owned_scopes": []any{read}}, + }, + } + if _, err := c.deliver(ctx, fixture, s.Solution, "solution", f.Generation, []string{s.Solution + ".note"}, doc, 0, + solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": both}}); err != nil { t.Fatalf("deliver %s: %v", s.Solution, err) } } @@ -103,6 +103,33 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur } } }) + t.Run("an existing resource is never pulled into another installation, by name", func(t *testing.T) { + // A read-only capability attaches nothing: A's viewer cannot pull B's + // collection into A, nor a read through B pull A's into B. + viewed, err := through(t, viewer, f.A) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(viewed, "token"), f.StoreCredential, f.ResourceB) + refused(t, err, codes.PermissionDenied, "holds no create") + readB, err := through(t, owner, f.B, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceB})) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(readB, "token"), f.StoreCredential, f.ResourceA) + refused(t, err, codes.PermissionDenied, "holds no create") + // A creating capability attaches only what no other installation owns. + for s, foreign := range map[OwnedInstallation]string{f.A: f.ResourceB, f.B: f.ResourceA} { + created, err := through(t, owner, s, create) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(created, "token"), f.StoreCredential, foreign) + refused(t, err, codes.PermissionDenied, "owned by") + } + _, err = through(t, owner, f.B, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceA})) + refused(t, err, codes.PermissionDenied, "widens") + }) t.Run("an attachment is refused without a serving module or an installation, by name", func(t *testing.T) { created, err := through(t, owner, f.A, create) if err != nil { diff --git a/modules/authority/services/edge/service.codefly.yaml b/modules/authority/services/edge/service.codefly.yaml index b88eabb6..0efa76cb 100644 --- a/modules/authority/services/edge/service.codefly.yaml +++ b/modules/authority/services/edge/service.codefly.yaml @@ -15,7 +15,7 @@ agent: # saas accounts implements the same interfaces — and the local authority that # implements all three when it names none (code/host.go). service-dependencies: - - interface: codefly.dev/authority@0.3.0 + - interface: codefly.dev/authority@>=0.2.0 <0.4.0 - interface: codefly.dev/session@0.2.0 - interface: codefly.dev/installation@0.1.0 workspace-configuration-dependencies: diff --git a/modules/authority/services/hop/service.codefly.yaml b/modules/authority/services/hop/service.codefly.yaml index 0688d89b..934acdf8 100644 --- a/modules/authority/services/hop/service.codefly.yaml +++ b/modules/authority/services/hop/service.codefly.yaml @@ -35,7 +35,7 @@ spec: # the composition names in the authority group's endpoint answers (saas # accounts, say), and the local authority when it names none (code/host.go). service-dependencies: - - interface: codefly.dev/authority@0.3.0 + - interface: codefly.dev/authority@>=0.2.0 <0.4.0 # The hop group: the run's proof, which the hop writes on every stamp so the # module behind it believes only stamps from this run's hop, and the module the # composition puts the hop in front of (MODULES). hop-routes is generated diff --git a/modules/authority/services/local/code/pkg/business/issuer/attach_test.go b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go new file mode 100644 index 00000000..0884dffc --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go @@ -0,0 +1,29 @@ +package issuer_test + +import ( + "errors" + "strings" + "testing" + + "gateway/pkg/business/issuer" + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// A kind two composed modules declare has no one module trusted to say what +// was created, so neither attaches anything of it. +func TestAttachmentNeedsTheKindsOneServingModule(t *testing.T) { + k := testkit.New(t) + decoy := state.Manifest{Module: "robin", Principal: "robin", Kind: "module", Generation: 1, + ResourceKinds: []*basev0.WorkScopeV1{testkit.Scope("documents.collection", "read")}} + if _, err := k.State.ApplyManifest(decoy); err != nil { + t.Fatal(err) + } + for _, module := range []string{"documents", "robin"} { + _, err := k.Issuer.AttachResource(t.Context(), "not-read", k.Credential(t, module), "documents.collection", "c1") + if !errors.Is(err, issuer.ErrRefused) || !strings.Contains(err.Error(), "one serving module") { + t.Fatalf("%s: %v", module, err) + } + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go index 71d14204..79115132 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go +++ b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go @@ -93,11 +93,14 @@ func roleResources(scopes []*basev0.WorkScopeV1, kind, action string) []resource return grants } -// AttachResource records that the module serving kind created or attached id -// under token, a capability addressed to that module and holding an action on -// kind. The owning installation is read from the capability's verified -// provenance, never named by the caller: an installation owning it, and the -// installation of each solution in its actor chain. The state requires one. +// AttachResource records that the module serving kind created id under +// token, a capability addressed to that module and holding create on the whole +// kind: attaching is for a resource the installation creates, so a capability +// that only reads, or acts on named ids, attaches nothing. The owning +// installation is read from the capability's verified provenance, never named +// by the caller: an installation owning it, the installation of each solution +// in its actor chain, and the module's own when it is the solution. The state +// requires exactly one, at the capability's authorization revision. func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id string) (string, error) { if kind == "" || id == "" { return "", fmt.Errorf("%w: an attachment names a resource kind and a resource id", ErrRefused) @@ -106,21 +109,31 @@ func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id if err != nil { return "", err } - if !holdsKind(actor.ResourceKinds, kind) { + // Only the one module serving a kind is trusted to say what it created; a + // kind two composed modules declare has no such module, so nothing of it + // is attached. + switch servers := i.state.ServersOf(kind); { + case !slices.Contains(servers, actor.Module): return "", fmt.Errorf("%w: %s does not serve %s, so it attaches none of its resources", ErrRefused, actor.Module, kind) + case len(servers) > 1: + return "", fmt.Errorf("%w: %s is served by %s; an attachment needs the kind's one serving module", ErrRefused, kind, strings.Join(servers, ", ")) } verified, err := i.verifier(actor.Principal).Verify(ctx, token) if err != nil { return "", err } - if !holdsKind(verified.EffectiveScopes(), kind) { - return "", fmt.Errorf("%w: the capability holds no action on %s, so nothing of it was created under it", ErrRefused, kind) + if !holdsCreate(verified.EffectiveScopes(), kind) { + return "", fmt.Errorf("%w: the capability holds no create on the whole of %s; an installation attaches only what it creates", ErrRefused, kind) } wc := verified.Context() + tenant := wc.GetTenantId() var candidates []string if wc.GetOwnerPrincipalKind() == "agent" { candidates = append(candidates, strings.TrimSuffix(wc.GetOwnerPrincipalId(), "/agent")) } + if actor.Kind == "solution" { + candidates = append(candidates, state.InstallationID(tenant, actor.Module)) + } for _, hop := range wc.GetActorChain() { if hop.GetPrincipalKind() != "service" { continue @@ -130,16 +143,21 @@ func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id return "", err } if m.Kind == "solution" { - candidates = append(candidates, state.InstallationID(wc.GetTenantId(), m.Module)) + candidates = append(candidates, state.InstallationID(tenant, m.Module)) } } - return i.state.AttachResource(wc.GetTenantId(), candidates, kind, id) + return i.state.AttachResource(tenant, wc.GetAuthorizationRevision(), candidates, kind, id) } -// holdsKind reports whether any scope grants an action on kind; a zero-action -// row is a denial, not a grant. -func holdsKind(scopes []*basev0.WorkScopeV1, kind string) bool { +// CreateAction is the action a creating call holds on the whole kind; it is +// what AttachResource requires of the capability it is presented. +const CreateAction = "create" + +// holdsCreate reports whether scopes hold create (or every action) on the +// whole of kind, not on named ids. +func holdsCreate(scopes []*basev0.WorkScopeV1, kind string) bool { return slices.ContainsFunc(scopes, func(scope *basev0.WorkScopeV1) bool { - return scope.GetResourceKind() == kind && len(scope.GetActions()) > 0 + return scope.GetResourceKind() == kind && len(scope.GetResourceIds()) == 0 && + (slices.Contains(scope.GetActions(), CreateAction) || slices.Contains(scope.GetActions(), state.AnyAction)) }) } diff --git a/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go index adbb9caf..369b363d 100644 --- a/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go +++ b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go @@ -3,6 +3,7 @@ package state_test import ( "context" "errors" + "fmt" "testing" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -46,7 +47,16 @@ func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) if _, err := k.State.ApplyManifest(ownedWiki(2)); err != nil { t.Fatal(err) } - id := state.InstallationID("acme", "wiki") + testkit.SolutionTools(t, k) + id, other := state.InstallationID("acme", "wiki"), state.InstallationID("acme", "proposals") + revision := func() uint64 { + t.Helper() + r, err := k.State.AuthorizationRevision(context.Background(), "acme") + if err != nil { + t.Fatal(err) + } + return r + } held := func() []*basev0.WorkScopeV1 { t.Helper() scopes, err := k.State.PersonSolutionScopes("maya", "wiki") @@ -58,18 +68,26 @@ func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) if got := held(); len(got) != 0 { t.Fatalf("an installation owning nothing grants nothing, never the whole kind: %v", got) } - if _, err := k.State.AttachResource("acme", nil, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { - t.Fatalf("no installation: %v", err) - } - if _, err := k.State.AttachResource("acme", []string{id, state.InstallationID("acme", "proposals")}, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { - t.Fatalf("two installations: %v", err) + refusals := map[string]struct { + tenant string + revision uint64 + candidates []string + id string + }{ + "no installation": {"acme", revision(), nil, "c1"}, + "two installations": {"acme", revision(), []string{id, other}, "c1"}, + "another organization": {"globex", revision(), []string{id}, "c1"}, + "a superseded revision": {"acme", revision() - 1, []string{id}, "c1"}, + "an id past its length": {"acme", revision(), []string{id}, string(make([]byte, state.MaxResourceIDBytes+1))}, } - if _, err := k.State.AttachResource("globex", []string{id}, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { - t.Fatalf("another organization: %v", err) + for name, r := range refusals { + if _, err := k.State.AttachResource(r.tenant, r.revision, r.candidates, "documents.collection", r.id); !errors.Is(err, state.ErrForbidden) { + t.Errorf("%s: want ErrForbidden, got %v", name, err) + } } before, _ := k.State.Installation(id) for range 2 { - if _, err := k.State.AttachResource("acme", []string{id}, "documents.collection", "c1"); err != nil { + if _, err := k.State.AttachResource("acme", revision(), []string{id}, "documents.collection", "c1"); err != nil { t.Fatal(err) } } @@ -79,6 +97,20 @@ func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) if len(before.Owned) != 0 { t.Fatalf("a copy read before the attachment changed under its reader: %v", before.Owned) } + if _, err := k.State.AttachResource("acme", revision(), []string{other}, "documents.collection", "c1"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("a resource has one owner in a tenant: %v", err) + } + for n := 1; n < state.MaxOwnedPerKind; n++ { + if _, err := k.State.AttachResource("acme", revision(), []string{id}, "documents.collection", fmt.Sprintf("c%d", n+1)); err != nil { + t.Fatal(err) + } + } + if _, err := k.State.AttachResource("acme", revision(), []string{id}, "documents.collection", "one-too-many"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("past the bound a capability carries: %v", err) + } + // A capability minted before an uninstall never attaches into the + // reinstalled installation, though its id is the same. + minted := revision() if err := k.State.Revoke(id); err != nil { t.Fatal(err) } @@ -91,4 +123,7 @@ func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) if got := held(); len(got) != 0 { t.Fatalf("a reinstalled installation inherited its predecessor's resources: %v", got) } + if _, err := k.State.AttachResource("acme", minted, []string{id}, "documents.collection", "late"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("a pre-uninstall capability attached into the reinstalled installation: %v", err) + } } diff --git a/modules/authority/services/local/code/pkg/business/state/solution_roles.go b/modules/authority/services/local/code/pkg/business/state/solution_roles.go index 6274c79a..5a808967 100644 --- a/modules/authority/services/local/code/pkg/business/state/solution_roles.go +++ b/modules/authority/services/local/code/pkg/business/state/solution_roles.go @@ -64,12 +64,28 @@ func roleGrants(m Manifest, role string, inst Installation) []*basev0.WorkScopeV return out } -// AttachResource records that id of kind was created or attached through one +// Owned ids travel in every capability that holds them, so they are bounded: +// an installation owns at most MaxOwnedPerKind resources of one kind, each id +// at most MaxResourceIDBytes long. Past either, AttachResource refuses by name. +const ( + MaxOwnedPerKind = 32 + MaxResourceIDBytes = 128 +) + +// AttachResource records that id of kind was created through one // installation. candidates are the installations the presenting capability's -// verified provenance names; exactly one active installation at tenant must -// remain. Attaching only widens what an installation-owned scope covers, so no -// revision moves: every capability already issued names its own ids. -func (s *State) AttachResource(tenant string, candidates []string, kind, id string) (string, error) { +// verified provenance names, and revision is the authorization revision it was +// minted at; exactly one active installation at tenant must remain, at the +// tenant's current revision, checked under the write lock so a capability from +// before an uninstall never attaches into the reinstalled installation. A +// resource has one owner in a tenant: an id another installation owns is +// refused, since attaching is for what an installation creates, never for an +// existing resource. Attaching only widens what an installation-owned scope +// covers, so no revision moves: every capability already issued names its ids. +func (s *State) AttachResource(tenant string, revision uint64, candidates []string, kind, id string) (string, error) { + if len(id) > MaxResourceIDBytes { + return "", fmt.Errorf("%w: resource id is %d bytes; an installation-owned id is at most %d", ErrForbidden, len(id), MaxResourceIDBytes) + } candidates = slices.Compact(slices.Sorted(slices.Values(candidates))) switch len(candidates) { case 0: @@ -80,6 +96,9 @@ func (s *State) AttachResource(tenant string, candidates []string, kind, id stri } s.mu.Lock() defer s.mu.Unlock() + if current := s.revisions[tenant] + 1; revision != current { + return "", fmt.Errorf("%w: authorization revision superseded: the capability was minted at revision %d, %s is at %d", ErrForbidden, revision, tenant, current) + } inst, ok := s.installations[candidates[0]] if !ok || inst.Organization != tenant || inst.Status != Active { return "", fmt.Errorf("%w: installation %s is not active at %s, so it owns nothing new", ErrForbidden, candidates[0], tenant) @@ -87,6 +106,14 @@ func (s *State) AttachResource(tenant string, candidates []string, kind, id stri if slices.Contains(inst.Owned[kind], id) { return inst.ID, nil } + for _, other := range s.installations { + if other.ID != inst.ID && other.Organization == tenant && slices.Contains(other.Owned[kind], id) { + return "", fmt.Errorf("%w: %s %q is owned by installation %s; an installation attaches only what it creates", ErrForbidden, kind, id, other.ID) + } + } + if len(inst.Owned[kind]) >= MaxOwnedPerKind { + return "", fmt.Errorf("%w: installation %s already owns %d of %s, the most a capability carries", ErrForbidden, inst.ID, MaxOwnedPerKind, kind) + } owned := maps.Clone(inst.Owned) if owned == nil { owned = map[string][]string{} diff --git a/modules/authority/services/local/code/pkg/business/state/state.go b/modules/authority/services/local/code/pkg/business/state/state.go index facd17cb..bbf41b7c 100644 --- a/modules/authority/services/local/code/pkg/business/state/state.go +++ b/modules/authority/services/local/code/pkg/business/state/state.go @@ -932,6 +932,20 @@ func (s *State) ensureModuleInstallations(m Manifest) []string { return ids } +// ServersOf names every composed module that serves kind, sorted. +func (s *State) ServersOf(kind string) []string { + s.mu.RLock() + defer s.mu.RUnlock() + var servers []string + for _, m := range s.modules { + if slices.ContainsFunc(m.ResourceKinds, func(sc *basev0.WorkScopeV1) bool { return sc.GetResourceKind() == kind }) { + servers = append(servers, m.Module) + } + } + sort.Strings(servers) + return servers +} + // Module returns a composed module by name. func (s *State) Module(name string) (Manifest, bool) { s.mu.RLock() diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index c9dc897a..631f374f 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -58,17 +58,24 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. @@ -192,17 +199,24 @@ type AuthorityServiceServer interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index 36dded0c..f47aa30a 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -80,17 +80,24 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. @@ -236,17 +243,24 @@ type AuthorityServiceHandler interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go index 3a7cd50d..a1286a31 100644 --- a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go @@ -516,7 +516,7 @@ type RoleV1 struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` // Scopes the role grants only on the resources the installation owns: those - // created or attached through it (AuthorityService.AttachResource). Each + // newly created through it (AuthorityService.AttachResource). Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go index 5c5d56aa..0a6a5abf 100644 --- a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go @@ -123,7 +123,7 @@ people: // both, and Maya's finance team holds A's viewer role and nothing of B's. owned := map[string]string{} for _, solution := range []string{"conformance-owner-a", "conformance-owner-b"} { - note := testkit.Scope("conformance.owned-note", "read") + note := testkit.Scope(solution+".note", "read") m := state.Manifest{Module: solution, Principal: solution, Kind: "solution", Generation: 1, ResourceKinds: []*basev0.WorkScopeV1{note}, Roles: map[string][]*basev0.WorkScopeV1{state.InstallerRole: {note}, "viewer": {note}}} if _, err := k.State.ApplyManifest(m); err != nil { diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index 3c1e50d1..976fb476 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -33,17 +33,24 @@ service AuthorityService { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse); - // AttachResource records that one resource was created or attached through - // an installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. The caller is the module that - // serves the kind, proving itself with its workload credential, and presents - // the capability the creating call carried, addressed to it and holding an - // action on the kind. The owning installation is read from that capability's + // AttachResource records that one resource was newly created through an + // installation, so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. It is for what the + // installation creates, never for an existing resource: a resource has one + // owner in an organisation, and an id another installation owns is refused. + // The caller is the kind's one serving module (a kind two composed modules + // declare attaches nothing), proving itself with its workload credential, + // and presents the capability the creating call carried: addressed to it, at + // the organisation's current authorization revision, and holding create on + // the whole kind; a capability that only reads, or acts on named ids, + // attaches nothing. The owning installation is read from that capability's // verified provenance and never named by the caller: the installation itself - // when it is the owner, otherwise the one solution in its actor chain. A - // capability issued through no installation, or through two, is refused. - // Attaching only widens what an installation-owned scope covers, so it moves - // no authorization revision; a reinstalled installation owns nothing. + // when it is the owner, the installed solution when it is the caller, and + // each solution in its actor chain; exactly one must result. Owned ids travel + // in every capability holding them, so an installation owns at most 32 of one + // kind, each id at most 128 bytes, refused by name past either. Attaching + // only widens what an owned scope covers, so it moves no authorization + // revision; a reinstalled installation owns nothing. rpc AttachResource(AttachResourceRequest) returns (AttachResourceResponse); // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/modules/authority/services/local/proto/host/composition/v1/composition.proto b/modules/authority/services/local/proto/host/composition/v1/composition.proto index 1d7441b5..f166f064 100644 --- a/modules/authority/services/local/proto/host/composition/v1/composition.proto +++ b/modules/authority/services/local/proto/host/composition/v1/composition.proto @@ -122,7 +122,7 @@ message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; // Scopes the role grants only on the resources the installation owns: those - // created or attached through it (AuthorityService.AttachResource). Each + // newly created through it (AuthorityService.AttachResource). Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/modules/console/services/api/service.codefly.yaml b/modules/console/services/api/service.codefly.yaml index 60a1f046..c244af69 100644 --- a/modules/console/services/api/service.codefly.yaml +++ b/modules/console/services/api/service.codefly.yaml @@ -32,7 +32,7 @@ spec: # The console reaches the authority through the interface it depends on, never a # service: the composition resolves which implementation answers. service-dependencies: - - interface: codefly.dev/authority@0.3.0 + - interface: codefly.dev/authority@>=0.2.0 <0.4.0 # The console acts as a workload of its own: the run vouches for it and delivers # its manifest to the authority. module-identity: true From 471d3e55e32cf2ce9d643eaeb381beca5a99c80a Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:30:37 -0400 Subject: [PATCH 3/4] feat: attach by create or by admin of the id; detach on delete (review r58) Coordinator decision: the only consumer configures collections it never created, and the document store's collection kind declares no create. So AttachResource accepts create on the whole kind (a new resource) or admin on that id, by name or on the whole kind (an administrator attaching an existing one). Read alone still attaches nothing, and an id another installation owns is still refused. F1: DetachResource, called by the kind's one serving module when it deletes a resource; the 32 bound counts what is owned now, and detaching moves the organisation's revision. The interface states that a store rolls back a create whose attach is refused and detaches what it deletes. F2: ids follow core's scope-value rule (non-empty, trimmed, no NUL, CR, LF or tab) and carry no "*"; they are opaque, compared byte for byte. F3: the actions read ("create", "admin") are named in the interface doc. F5: positive tests for the solution-own-kind and installation-agent provenance paths. F7: the README states that local ownership is in memory, for development. Conformance adds: an admin of X attaches X and A's viewer then reads it; a reader of X cannot; an admin of X cannot attach Y; an admin of B's id cannot take it into A; a detached resource leaves its owner and attaches again. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +- interfaces/authority/interface.codefly.yaml | 2 +- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 377528 -> 379477 bytes .../go/gen/host/authority/v1/authority.pb.go | 209 ++++++++++++++---- .../host/authority/v1/authority_grpc.pb.go | 122 +++++++--- .../gen/host/composition/v1/composition.pb.go | 3 +- .../authority-client/library.codefly.yaml | 2 +- .../src/gen/host/authority/v1/authority_pb.ts | 118 ++++++++-- .../gen/host/composition/v1/composition_pb.ts | 3 +- libraries/conformance/go/README.md | 2 +- .../conformance/go/installation_resources.go | 97 ++++++-- .../code/pkg/business/issuer/attach_test.go | 91 ++++++++ .../pkg/business/issuer/solution_roles.go | 95 +++++--- .../business/state/installation_owned_test.go | 33 ++- .../code/pkg/business/state/solution_roles.go | 53 ++++- .../pkg/gen/host/authority/v1/authority.pb.go | 209 ++++++++++++++---- .../host/authority/v1/authority_grpc.pb.go | 122 +++++++--- .../authorityv1connect/authority.connect.go | 113 +++++++--- .../gen/host/composition/v1/composition.pb.go | 3 +- .../local/code/plugins/authority/plugin.go | 9 + .../registry_test/local_authority_test.go | 2 +- .../proto/host/authority/v1/authority.proto | 61 +++-- .../host/composition/v1/composition.proto | 3 +- 24 files changed, 1093 insertions(+), 284 deletions(-) diff --git a/README.md b/README.md index 96260266..e6e7a358 100644 --- a/README.md +++ b/README.md @@ -942,11 +942,20 @@ All consumers and implementations must select the updated interface versions; this repository updates its module, edge, hop and console declarations. SaaS must adopt the version and fields before claiming this contract. -`codefly.dev/authority@0.3.0` adds `AttachResource` and +`codefly.dev/authority@0.3.0` adds `AttachResource` and `DetachResource`, and `codefly.dev/composition@0.2.0` adds `RoleV1.installation_owned_scopes`: a role -scope held only on the resources its installation created, recorded from the -creating capability's provenance (one owner per resource, at most 32 per kind). -Both are additive. The local authority implements 0.3.0 and 0.2.0; the edge, -hop and console call nothing new, so they require `>=0.2.0 <0.4.0` and resolve -against a host still at 0.2.0. Shared conformance requires -`InstallationResources`, so a host must implement both before it passes. +scope held only on the resources its installation owns. The kind's one serving +module attaches a resource from a capability holding `create` on the whole kind +(it created it) or `admin` on that id (an administrator attaches an existing +resource); reading attaches nothing. A resource has one owner per organisation, +an installation owns at most 32 of a kind at a time, and the serving module +detaches what it deletes and rolls back a create whose attach is refused. Both +are additive. The local authority implements 0.3.0 and 0.2.0; the edge, hop and +console call nothing new, so they require `>=0.2.0 <0.4.0` and resolve against +a host still at 0.2.0. Shared conformance requires `InstallationResources`, so +a host must implement both before it passes. + +The local authority keeps installation ownership in memory, for development +only: a restart (a hot reload included) re-derives installations from the +fixture but not what they own, so viewers lose owned resources until the store +attaches them again. A host keeps ownership durably. diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index 5bd71c7e..0378c59f 100644 --- a/interfaces/authority/interface.codefly.yaml +++ b/interfaces/authority/interface.codefly.yaml @@ -8,4 +8,4 @@ protobuf: package: host.authority.v1 services: - name: AuthorityService - methods: [Issue, Check, Keys, ListResources, AttachResource, Delegate, RevokeDelegation, GetDelegation] + methods: [Issue, Check, Keys, ListResources, AttachResource, DetachResource, Delegate, RevokeDelegation, GetDelegation] diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index a164b48a..7477a75e 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:8e18b679051680b657a5e22de7e13bf7ae3301e77adc6736bc259e7234cd3713", + "digest": "sha256:86254f7a97f7f1b002edbe18b66a664c6aaf5d26a1b03f95837e6916db66f9f0", "services": [ { "name": "GatewayService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index f0ab9ed9ab1d49a2d568664fbc0a2a62054269b0..efbcf4bd017ea9a1e42b92fe63960757436e2b2c 100644 GIT binary patch delta 6682 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 delta 5321 zcmZu#Yiv}<72cV>b9eWy@#A7+FnGNL62N9RfN?N(c$kL)2V)3^fMWuSz1SPfu48X- zEJPrLSB#Swj2I&f#xamoDQzhf2N9~K1XZd){XwIYq7pSyN~nY=qEdn)M87llUTCHM z+i&jqzVnzfbH@JaVD@LbvbXPKJNZucPVJnSm(L$}4wmPOorb(#BsR0|k1JjfeDF-M zC}+OkG#egMxLlu{FAh7xBuRE<=7XTjUqCMOuULXj@Zz zu&yNAFTa*mOT7w%BEy1?NND|i3 z|24C9S|>twbF@WSM&iNfTd~$;EZz`oiMA%*Y^jTu1S3y=2OAMZA`YcQTO6EN9NvS8 zmWD_?)@nJ&LOm_=SiSt~T=86*5v*>E2I~+V5nAQ71tL%Omx)o#keABDNO_}7gwhSg zYlhQkJlPsd%Bp5Dj+MyKb3`T#i{^;JZOWuE(Ns_2SyezV2Bn4^6HU?J##p?*6!F$Y znj`CDO|fJX9u95~+iWL_8ReY(gDlScUs!GE&zVMUfN9MmPwou#qM? zQ{|N%^F%g}B;`l*#5WmLD1hPylP!_W(I!I{mWz@?=ma+`u1eBReo45va_Qxq|p)%mZ>=46g5WF@w^V8iL{UMJ zo@t~Qg4%?mPqOkI55t1tr=Dt?36P4@0uBPHctWAELUDA{ zT_Ue763d_0!70&)*;YC@CD~yIfm1SlnQ@6LlE-w<8my`ir7Y|LYnDFLw)22BD|5Vq zz?xM!!>BO0<}$Upu1)f@mqbPSTyLf^&CoQL7pwF6N@*?@({kr~4dQ7o?m|!twG5*J zX%PgH8y1Vs{DtOl#{|*~M~<=)1EE42X3SOgh^Ru2suc4JDzft&3)ojYGsZ?7_LbUD z#!*Zs{H3!6Sd2p%7&tK)s za7;m5m6>HD9pW|IFlrSMerx3SOT}x&HGZ{~@J<_Blb!3>gIyDJ+{JYD>ZPz`k~vlcpE;YY}FV)t-(v48Z0WPTuRze*V%ME> zj&!ynf@Mkv4Zc;bSS1#Xvverht-f?S0uJWZp_#@Ubt^dwJ>z`T=_876+aln-3{|%wl=d=|-GVs9+IvrPwNRG&Sm@@smGSjS-~!zD zo(8VNdLQGuu}}pLxBKLy8c`A4$2>!9eI1m2EIq^6PEDwpdKYsYlnt+nZL7PO*D9N) zHfz5V1)!+`S(^ofAoe?(1qj9c&Ss&F;kKLM_|bJjU9EM?S+(M|yl#f$M@eE4lx~(A zFqR?)Vd|2(4$IGLMb%Qtym`tuPe z%%JRl9@k&%8WCRH%e+A)t^&5;rHY0dVHpKg7G0r&*OvM3Fc2x0zlCFZ-^FDP!SDNJIOfRW18kMKs?DX z5U!Ccuc?2^T)&W$){3ivPnp*$o>$f7G}Yy0Z~#s1w9Hv2s`E}WpVg08M9`-h?%ahC z@|pS><~k?eUndrfKErUw&@T9t&{+~13n4&L1Im@}oDm~rYD5@$XPJGh@DYM%=~yj; zpkeBR%=KG2Eh3f%2B`=nXehx8BsdF#fTngqo{WgH&;{m8b!;GXfn`{?g2Cx%Ut|~` z6BPxex+sUP7va!FCr;Raa*=kx@`BN;U1B^GdkdR-6dzr$W0N;S7c3IA>69u8mPPkZv&}AoFzY6yXQC2Am z!o4C7)rp$$6^7wz$x^si7^bTQ=>ob+lwZ3Q2k~AV4AhHrEPU0uPy8z2tImDm=X3>K zr;~i%B0=){`3>STmUo?btUCxsv`U=mNHWFLzh7cZ&0tbr?wo^YWLi@H;zf>B6+8w%&^A!at+>wK?i;3Yo%x`T! zY-p8bjZi57Lg5Y@l?yLiOEdMm%=K@Xu~AG8++|*CZ_`vL_b8MwbOBB6o?N$4ObXp| zLct=0?m3~PscPOQ%49`>)BEz$Mo}2L&+xdQ^1%j_`z*_v5NWEK59m-(V*;Am1DV?- z3i2K};}naaJRl04@N`rEfw{KJh9k%ul5Y|MG_{A+;m;Q0Hv)O+^f@5p z9y)!Vu7duFh}7qRruGx{dG%EMCg5~CAc#LX-JY&Oe?&y;9Y9li#N^L6iTUA2&TWlV za2`1mBV9H7W8zF#9DIDtWL{j91Rpz9g^^lDv;RnUV}_~k;;uciJuZHz*~PuqFwNjD z?E7wR&tX7?#;Pg<;o{iM{nkqX5Q*&OBdm@FgoE829UXzbY3d!^)h+*+5bFXRj=rf1 z+Q|_r-MxUO)+rY@i|SA(w{JKsLa3A5H=L>F;RhV^uu@Ur^aHuJRV*sTCj#7_ihv+~ zz%do6DNI$$F7j2Xh$tn{$efV;)h7HA9DPINhPt@@WP&xwUEF>$nX0A-h+M45_;^76 z@(odvcYyn>0{|OZ<=MlH)yR0LsUPB)!rm6KDsYHC0W8$bY;ks>?EwVK2vkNCi6+f2`?8IscfuRf@p)e3W~ww@Ie* z)rY}n6p%If>XTbGi*bec4a+|6vt}wFJofQ4>j9Uk+O;2ZN%c9PsrAdRHlxq+n*jGa zE~v)+9M2!Bai*&A01>J608MQ`hT25YRQv{Ufcu|Vf;hnKTRl_N_Y@Hi(jOVXrglmm zY7<>!@Egoi+;1IjK>EhBhOO6|TN24cp>b?*(OY7**Z28s>)$yi20z^{u4S`#WqXhK zT^1h6hVd`D_jL*ga6!)B<&wjqa`^xMe@-kp_@~2SFAMKr{_!d?5D~);Herqf5yK8P zzuYLt4&e>6n;w0;j)?hz-OlCaQRT-Y)@#gwZ$P|D<@ZO#tk7QO^D7}NLTE2bwMLLf z6~2Qgg^B{F9kRMt6iw-HZV1?b(&5|?9*)DMbyA8&ih>M*N^{1^Nk>Jl{7bLMn$hXx z0c%KhvI#HP?y1h5Y4owc+?q?`A5Z%x86@^-McVk8$coubNs%C>DB<0h-zodA3hH7e2!5KExueGSiw` zfC%LXO|4Rdf`?uobDfZ$w(wr3;>_n h*Z~?rbQA&UV0?ZY|98Wi*NPsC@I5g2@VKa*@E host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 21, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 23, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 21, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 21, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 23, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 23, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 22, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 22, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 24, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 24, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 20, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 22, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 15: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 16: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 17: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 18: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 19: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 2, // 20: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 21: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 22: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 23: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 24: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 25: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 26: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 27: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 3, // 28: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 29: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 30: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 23, // [23:31] is the sub-list for method output_type - 15, // [15:23] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 16: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 17: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 18: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 19: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 20: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 20, // 21: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 2, // 22: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 23: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 24: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 25: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 26: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 27: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 28: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 29: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 21, // 30: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 3, // 31: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 32: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 33: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 25, // [25:34] is the sub-list for method output_type + 16, // [16:25] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1655,7 +1788,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[20].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[22].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1667,7 +1800,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 21, + NumMessages: 23, NumExtensions: 0, NumServices: 1, }, diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go index 631f374f..c084d542 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go @@ -31,6 +31,7 @@ const ( AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" + AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" @@ -58,25 +59,36 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + DetachResource(ctx context.Context, in *DetachResourceRequest, opts ...grpc.CallOption) (*DetachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) @@ -147,6 +159,16 @@ func (c *authorityServiceClient) AttachResource(ctx context.Context, in *AttachR return out, nil } +func (c *authorityServiceClient) DetachResource(ctx context.Context, in *DetachResourceRequest, opts ...grpc.CallOption) (*DetachResourceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DetachResourceResponse) + err := c.cc.Invoke(ctx, AuthorityService_DetachResource_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Delegation) @@ -199,25 +221,36 @@ type AuthorityServiceServer interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + DetachResource(context.Context, *DetachResourceRequest) (*DetachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *DelegateRequest) (*Delegation, error) @@ -253,6 +286,9 @@ func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListR func (UnimplementedAuthorityServiceServer) AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) { return nil, status.Error(codes.Unimplemented, "method AttachResource not implemented") } +func (UnimplementedAuthorityServiceServer) DetachResource(context.Context, *DetachResourceRequest) (*DetachResourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DetachResource not implemented") +} func (UnimplementedAuthorityServiceServer) Delegate(context.Context, *DelegateRequest) (*Delegation, error) { return nil, status.Error(codes.Unimplemented, "method Delegate not implemented") } @@ -373,6 +409,24 @@ func _AuthorityService_AttachResource_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } +func _AuthorityService_DetachResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DetachResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).DetachResource(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_DetachResource_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).DetachResource(ctx, req.(*DetachResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_Delegate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DelegateRequest) if err := dec(in); err != nil { @@ -454,6 +508,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AttachResource", Handler: _AuthorityService_AttachResource_Handler, }, + { + MethodName: "DetachResource", + Handler: _AuthorityService_DetachResource_Handler, + }, { MethodName: "Delegate", Handler: _AuthorityService_Delegate_Handler, diff --git a/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go b/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go index faed3ecd..b1d12a80 100644 --- a/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go +++ b/libraries/authority-client/go/gen/host/composition/v1/composition.pb.go @@ -516,7 +516,8 @@ type RoleV1 struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` // Scopes the role grants only on the resources the installation owns: those - // newly created through it (AuthorityService.AttachResource). Each + // created through it or attached by its administrator + // (AuthorityService.AttachResource). Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/libraries/authority-client/library.codefly.yaml b/libraries/authority-client/library.codefly.yaml index c8490063..9423bb88 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,7 +20,7 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:8e18b679051680b657a5e22de7e13bf7ae3301e77adc6736bc259e7234cd3713 + contract-digest: sha256:86254f7a97f7f1b002edbe18b66a664c6aaf5d26a1b03f95837e6916db66f9f0 services: - GatewayService generated-by: diff --git a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts index 36dc9ca2..696a563a 100644 --- a/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/authority/v1/authority_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file host/authority/v1/authority.proto. */ export const file_host_authority_v1_authority: GenFile = /*@__PURE__*/ - fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiWgoNUmVzb3VyY2VHcmFudBINCgNhbGwYASABKAhIABIMCgJpZBgCIAEoCUgAEhAKBnByZWZpeBgDIAEoCUgAEhEKB3BhdHRlcm4YBCABKAlIAEIHCgVtYXRjaDKTBgoQQXV0aG9yaXR5U2VydmljZRJqCgVJc3N1ZRIfLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLklzc3VlUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTppc3N1ZRJqCgVDaGVjaxIfLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVxdWVzdBogLmhvc3QuYXV0aG9yaXR5LnYxLkNoZWNrUmVzcG9uc2UiHoLT5JMCGDoBKiITL3YxL2F1dGhvcml0eTpjaGVjaxJHCgRLZXlzEh4uaG9zdC5hdXRob3JpdHkudjEuS2V5c1JlcXVlc3QaHy5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVzcG9uc2USYgoNTGlzdFJlc291cmNlcxInLmhvc3QuYXV0aG9yaXR5LnYxLkxpc3RSZXNvdXJjZXNSZXF1ZXN0GiguaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1Jlc3BvbnNlEmUKDkF0dGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuQXR0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuQXR0YWNoUmVzb3VyY2VSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkikgEKFURldGFjaFJlc291cmNlUmVxdWVzdBI0CgVhY3RvchgBIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRITCgtyZXNvdXJjZV9pZBgEIAEoCSIxChZEZXRhY2hSZXNvdXJjZVJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCSJaCg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAQgcKBW1hdGNoMvoGChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USZQoOQXR0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlc3BvbnNlEmUKDkRldGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXNwb25zZRJNCghEZWxlZ2F0ZRIiLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRlUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb24SawoQUmV2b2tlRGVsZWdhdGlvbhIqLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXF1ZXN0GisuaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlc3BvbnNlElcKDUdldERlbGVnYXRpb24SJy5ob3N0LmF1dGhvcml0eS52MS5HZXREZWxlZ2F0aW9uUmVxdWVzdBodLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25CL1otZ2F0ZXdheS9wa2cvZ2VuL2hvc3QvYXV0aG9yaXR5L3YxO2F1dGhvcml0eXYxYgZwcm90bzM", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -710,6 +710,69 @@ export type AttachResourceResponse = Message<"host.authority.v1.AttachResourceRe export const AttachResourceResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_host_authority_v1_authority, 19); +/** + * DetachResourceRequest names one resource its serving module deleted. + * + * @generated from message host.authority.v1.DetachResourceRequest + */ +export type DetachResourceRequest = Message<"host.authority.v1.DetachResourceRequest"> & { + /** + * The module serving resource_kind, proving which module it is. + * + * @generated from field: host.authority.v1.WorkloadCredential actor = 1; + */ + actor?: WorkloadCredential; + + /** + * The organisation the resource belongs to. + * + * @generated from field: string organization_id = 2; + */ + organizationId: string; + + /** + * The resource kind, as the caller serves it. + * + * @generated from field: string resource_kind = 3; + */ + resourceKind: string; + + /** + * The resource id, as the caller knows it. + * + * @generated from field: string resource_id = 4; + */ + resourceId: string; +}; + +/** + * Describes the message host.authority.v1.DetachResourceRequest. + * Use `create(DetachResourceRequestSchema)` to create a new message. + */ +export const DetachResourceRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 20); + +/** + * DetachResourceResponse names the installation that owned the resource. + * + * @generated from message host.authority.v1.DetachResourceResponse + */ +export type DetachResourceResponse = Message<"host.authority.v1.DetachResourceResponse"> & { + /** + * The installation released from it; empty when none owned it. + * + * @generated from field: string installation_id = 1; + */ + installationId: string; +}; + +/** + * Describes the message host.authority.v1.DetachResourceResponse. + * Use `create(DetachResourceResponseSchema)` to create a new message. + */ +export const DetachResourceResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_host_authority_v1_authority, 21); + /** * ResourceGrant is one way of naming covered resources. Exactly one is set. * @@ -760,7 +823,7 @@ export type ResourceGrant = Message<"host.authority.v1.ResourceGrant"> & { * Use `create(ResourceGrantSchema)` to create a new message. */ export const ResourceGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 20); + messageDesc(file_host_authority_v1_authority, 22); /** * AuthorityService is implemented by the host (accounts) and by the local authority. @@ -817,24 +880,29 @@ export const AuthorityService: GenService<{ output: typeof ListResourcesResponseSchema; }, /** - * AttachResource records that one resource was newly created through an - * installation, so a role scope its solution declares installation-owned - * (RoleV1.installation_owned_scopes) covers it. It is for what the - * installation creates, never for an existing resource: a resource has one - * owner in an organisation, and an id another installation owns is refused. - * The caller is the kind's one serving module (a kind two composed modules - * declare attaches nothing), proving itself with its workload credential, - * and presents the capability the creating call carried: addressed to it, at - * the organisation's current authorization revision, and holding create on - * the whole kind; a capability that only reads, or acts on named ids, - * attaches nothing. The owning installation is read from that capability's + * AttachResource records that one resource now belongs to an installation, + * so a role scope its solution declares installation-owned + * (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + * serving module (a kind two composed modules declare attaches nothing), + * proving itself with its workload credential, and presents a capability + * addressed to it, at the organisation's current authorization revision, + * holding either the action "create" on the whole kind (the call that created + * the resource) or the action "admin" on that resource id, by name or on the + * whole kind (an administrator attaching an existing resource, as an + * installer configuring a solution's collection). A capability that only + * reads attaches nothing. A resource has one owner in an organisation: an id + * another installation owns is refused. A store whose attach is refused fails + * or rolls back the create it made, and detaches what it deletes + * (DetachResource). The owning installation is read from the capability's * verified provenance and never named by the caller: the installation itself * when it is the owner, the installed solution when it is the caller, and - * each solution in its actor chain; exactly one must result. Owned ids travel - * in every capability holding them, so an installation owns at most 32 of one - * kind, each id at most 128 bytes, refused by name past either. Attaching - * only widens what an owned scope covers, so it moves no authorization - * revision; a reinstalled installation owns nothing. + * each solution in its actor chain; exactly one must result. Ids are opaque + * and compared byte for byte; an id is refused when it is empty, carries + * surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + * Owned ids travel in every capability holding them, so an installation owns + * at most 32 of one kind at a time, refused by name past it. Attaching only + * widens what an owned scope covers, so it moves no authorization revision; + * a reinstalled installation owns nothing. * * @generated from rpc host.authority.v1.AuthorityService.AttachResource */ @@ -843,6 +911,20 @@ export const AuthorityService: GenService<{ input: typeof AttachResourceRequestSchema; output: typeof AttachResourceResponseSchema; }, + /** + * DetachResource releases one resource from whichever installation of the + * organisation owns it, when the kind's one serving module deletes it, so the + * id no longer counts toward the owner's bound and may be attached again. + * It narrows the owner's roles, so the organisation's authorization revision + * moves. Detaching an id no installation owns answers no installation. + * + * @generated from rpc host.authority.v1.AuthorityService.DetachResource + */ + detachResource: { + methodKind: "unary"; + input: typeof DetachResourceRequestSchema; + output: typeof DetachResourceResponseSchema; + }, /** * Delegate records a signed-in person's grant to a module (the caller's * bearer is the person); Issue with a delegation acts on it. diff --git a/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts b/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts index f1670b39..0c106423 100644 --- a/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts +++ b/libraries/authority-client/typescript/src/gen/host/composition/v1/composition_pb.ts @@ -327,7 +327,8 @@ export type RoleV1 = Message<"host.composition.v1.RoleV1"> & { /** * Scopes the role grants only on the resources the installation owns: those - * newly created through it (AuthorityService.AttachResource). Each + * created through it or attached by its administrator + * (AuthorityService.AttachResource). Each * names a kind and actions and no resource ids, since ids are per * installation, and is held to the same ceiling as scopes. The authority * resolves each, for the installation the role is held on, into the same diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 7356d68c..a9d38542 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -24,7 +24,7 @@ providing different labels does not remove an obligation. | Delivered solution roles at their own audience, binding roots and children; no-role refusals; ceiling enforcement | `SolutionRoles`: a signed wiki viewer role grants Maya documents write, which she lacks independently. Replayed deliveries use deterministic authority-document bytes. | | Rule A: current owner grants intersected with each target binding; canonical `//` identity; named hop refusals | `Chain`, `ChildScopes`, and the individual binding-refusal cases | | Enduring narrowing through two hops | `ChildScopes` and `ParentLimits`: stored delegations retain complete ceilings; parent action/resource limits and entirely dropped kinds remain capped across target changes. Multiple action/resource rows must not become a cross product. | -| Installation-owned role scopes: a role scope limited to what the installation created, attached from the creating capability's provenance; a read-only capability attaches nothing and an id another installation owns is refused, by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, and a store module serving the shared kind; the suite delivers all three | +| Installation-owned role scopes: a role scope limited to what the installation owns, attached from the presenting capability's provenance by `create` on the kind or `admin` on the id; a read-only capability, an administrator of another id, and an id another installation owns are refused, by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens; a detached resource leaves its owner and may be attached again | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, a store module serving the shared kind, and two existing unowned resources; the suite delivers all three modules | | Installation identity, installed caller, explicit organization, pending, active and revoked states | `InstalledSolution`, `InstalledCredential`, `Lifecycle` | | Absent installation versus no entitlement | `FailedPrecondition` with the absent-installation reason versus `PermissionDenied` with the holds-nothing reason | | Inventory access | Admin and owner allowed; ordinary member, anonymous and foreign-organization reads refused | diff --git a/libraries/conformance/go/installation_resources.go b/libraries/conformance/go/installation_resources.go index bc4a8354..497eb076 100644 --- a/libraries/conformance/go/installation_resources.go +++ b/libraries/conformance/go/installation_resources.go @@ -24,7 +24,9 @@ type InstallationResourcesCase struct { // Generation the suite delivers Store, A and B at. Generation uint64 // ResourceA and ResourceA2 are created through A, ResourceB through B. - ResourceA, ResourceA2, ResourceB string + // ResourceX and ResourceY exist and no installation owns them: Owner, as + // A's installer, administers both, and attaches X only. + ResourceA, ResourceA2, ResourceB, ResourceX, ResourceY string } // OwnedInstallation is one installed solution of the case. @@ -35,31 +37,31 @@ type OwnedInstallation struct { func (c *client) installationResources(t *testing.T, ctx context.Context, fixture Fixture) { f := fixture.InstallationResources if f == nil || fixture.SignManifest == nil || fixture.SignAuthority == nil || f.Owner == "" || f.Viewer == "" || f.A.Installation == "" || f.B.Installation == "" || - f.StoreCredential == "" || f.Generation == 0 || f.ResourceA == "" || f.ResourceA == f.ResourceA2 || f.ResourceA == f.ResourceB || f.ResourceA2 == f.ResourceB { - t.Fatal("required conformance fixture missing: signed delivery, two owned installations and three distinct resources") + f.StoreCredential == "" || f.Generation == 0 || len(slices.Compact(slices.Sorted(slices.Values([]string{f.ResourceA, f.ResourceA2, f.ResourceB, f.ResourceX, f.ResourceY, ""})))) != 6 { + t.Fatal("required conformance fixture missing: signed delivery, two owned installations and five distinct resources") } const binding, self = "store", "self" - create, read := scopeAsk(f.Kind, []string{"create"}, nil), scopeAsk(f.Kind, []string{"read"}, nil) + create, read, admin := scopeAsk(f.Kind, []string{"create"}, nil), scopeAsk(f.Kind, []string{"read"}, nil), scopeAsk(f.Kind, []string{"admin"}, nil) // Store serves Kind; its self binding lets it hop on, and work headless. both := []string{f.Kind + ":create", f.Kind + ":read"} if _, err := c.deliver(ctx, fixture, f.Store, "module", f.Generation, []string{f.Kind}, - map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, []string{"create", "read"}, nil)}}, 0, + map[string]any{"scope_ceilings": []any{scopeAsk(f.Kind, []string{"admin", "create", "read"}, nil)}}, 0, solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": both, "headless": both}}); err != nil { t.Fatalf("deliver %s: %v", f.Store, err) } - // Each installer may create anywhere; each role reads only what its own - // installation owns. + // Each installer may create, and reads and administers every resource of + // the kind; a viewer reads only what its own installation owns. for _, s := range []OwnedInstallation{f.A, f.B} { own := scopeAsk(s.Solution+".note", []string{"read"}, nil) doc := map[string]any{ "scope_ceilings": []any{own}, "roles": []any{ - map[string]any{"name": "installer", "scopes": []any{own, create}, "installation_owned_scopes": []any{read}}, + map[string]any{"name": "installer", "scopes": []any{own, create, read, admin}}, map[string]any{"name": "viewer", "scopes": []any{own}, "installation_owned_scopes": []any{read}}, }, } if _, err := c.deliver(ctx, fixture, s.Solution, "solution", f.Generation, []string{s.Solution + ".note"}, doc, 0, - solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": both}}); err != nil { + solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": append(both, f.Kind+":admin")}}); err != nil { t.Fatalf("deliver %s: %v", s.Solution, err) } } @@ -111,24 +113,25 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur t.Fatal(err) } _, err = attach(str(viewed, "token"), f.StoreCredential, f.ResourceB) - refused(t, err, codes.PermissionDenied, "holds no create") + refused(t, err, codes.PermissionDenied, "holds neither") readB, err := through(t, owner, f.B, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceB})) if err != nil { t.Fatal(err) } _, err = attach(str(readB, "token"), f.StoreCredential, f.ResourceA) - refused(t, err, codes.PermissionDenied, "holds no create") - // A creating capability attaches only what no other installation owns. + refused(t, err, codes.PermissionDenied, "holds neither") + // A creating capability, or one administering the very id, attaches + // only what no other installation owns. for s, foreign := range map[OwnedInstallation]string{f.A: f.ResourceB, f.B: f.ResourceA} { - created, err := through(t, owner, s, create) - if err != nil { - t.Fatal(err) + for _, ask := range []any{create, scopeAsk(f.Kind, []string{"admin"}, []string{foreign})} { + capability, err := through(t, owner, s, ask) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(capability, "token"), f.StoreCredential, foreign) + refused(t, err, codes.PermissionDenied, "owned by") } - _, err = attach(str(created, "token"), f.StoreCredential, foreign) - refused(t, err, codes.PermissionDenied, "owned by") } - _, err = through(t, owner, f.B, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceA})) - refused(t, err, codes.PermissionDenied, "widens") }) t.Run("an attachment is refused without a serving module or an installation, by name", func(t *testing.T) { created, err := through(t, owner, f.A, create) @@ -187,4 +190,60 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur refused(t, err, codes.PermissionDenied, "widens") } }) + t.Run("an administrator of an existing resource attaches it, and only it", func(t *testing.T) { + reads, err := through(t, owner, f.A, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceX})) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(reads, "token"), f.StoreCredential, f.ResourceX) + refused(t, err, codes.PermissionDenied, "holds neither") + administers, err := through(t, owner, f.A, scopeAsk(f.Kind, []string{"admin"}, []string{f.ResourceX})) + if err != nil { + t.Fatal(err) + } + _, err = attach(str(administers, "token"), f.StoreCredential, f.ResourceY) + refused(t, err, codes.PermissionDenied, "holds neither") + out, err := attach(str(administers, "token"), f.StoreCredential, f.ResourceX) + if err != nil { + t.Fatal(err) + } + if got := str(out, "installation_id"); got != f.A.Installation { + t.Fatalf("%s attached to %s, want %s", f.ResourceX, got, f.A.Installation) + } + viewed, err := through(t, viewer, f.A) + if err != nil { + t.Fatal(err) + } + exactEffectiveScope(t, msg(viewed, "context"), f.Kind, []string{"read"}, []string{f.ResourceA, f.ResourceA2, f.ResourceX}) + }) + t.Run("a deleted resource is detached by its serving module and may be attached again", func(t *testing.T) { + detach := func(credential, id string) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "DetachResource", map[string]any{"actor": map[string]any{"credential": credential}, + "organization_id": fixture.Organization, "resource_kind": f.Kind, "resource_id": id}) + } + _, err := detach(f.A.Credential, f.ResourceA2) + refused(t, err, codes.PermissionDenied, "does not serve") + out, err := detach(f.StoreCredential, f.ResourceA2) + if err != nil { + t.Fatal(err) + } + if got := str(out, "installation_id"); got != f.A.Installation { + t.Fatalf("%s detached from %q, want %s", f.ResourceA2, got, f.A.Installation) + } + viewed, err := through(t, viewer, f.A) + if err != nil { + t.Fatal(err) + } + exactEffectiveScope(t, msg(viewed, "context"), f.Kind, []string{"read"}, []string{f.ResourceA, f.ResourceX}) + created, err := through(t, owner, f.B, create) + if err != nil { + t.Fatal(err) + } + if out, err = attach(str(created, "token"), f.StoreCredential, f.ResourceA2); err != nil || str(out, "installation_id") != f.B.Installation { + t.Fatalf("a re-created id attaches to its new owner: %v %v", out, err) + } + if out, err = detach(f.StoreCredential, f.ResourceY); err != nil || str(out, "installation_id") != "" { + t.Fatalf("detaching an id nobody owns answers no installation: %v %v", out, err) + } + }) } diff --git a/modules/authority/services/local/code/pkg/business/issuer/attach_test.go b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go index 0884dffc..90e6db60 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/attach_test.go +++ b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go @@ -1,13 +1,16 @@ package issuer_test import ( + "context" "errors" + "slices" "strings" "testing" "gateway/pkg/business/issuer" "gateway/pkg/business/state" "gateway/pkg/business/testkit" + authorityv1 "gateway/pkg/gen/host/authority/v1" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) @@ -27,3 +30,91 @@ func TestAttachmentNeedsTheKindsOneServingModule(t *testing.T) { } } } + +// ownKindSolution installs a solution serving its own kind: its installer +// creates items, its viewer reads the items and administers collections at +// documents, and reads the collections its installation owns. +func ownKindSolution(t *testing.T, k *testkit.Kit) string { + t.Helper() + m := state.Manifest{Module: "proposals", Principal: "proposals", Kind: "solution", Generation: 1, + ResourceKinds: []*basev0.WorkScopeV1{testkit.Scope("proposals.item", "create", "read")}, + Bindings: map[string]state.Binding{"documents": {ID: "documents", Audience: "documents", + InvokeScopes: []*basev0.WorkScopeV1{testkit.Scope("documents.collection", "read", "admin")}}}, + Roles: map[string][]*basev0.WorkScopeV1{ + state.InstallerRole: {testkit.Scope("proposals.item", "create")}, + "viewer": {testkit.Scope("documents.collection", "admin")}, + }, + OwnedRoles: map[string][]*basev0.WorkScopeV1{ + state.InstallerRole: {testkit.Scope("proposals.item", "read")}, + "viewer": {testkit.Scope("proposals.item", "read"), testkit.Scope("documents.collection", "read")}, + }} + if _, err := k.State.ApplyManifest(m); err != nil { + t.Fatal(err) + } + inst, err := k.State.Install(context.Background(), "acme", "proposals", "viewer", "alex") + if err != nil { + t.Fatal(err) + } + if _, err := k.State.GrantToTeam(context.Background(), "acme", inst.ID, "finance"); err != nil { + t.Fatal(err) + } + return inst.ID +} + +func ownedRead(scopes []*basev0.WorkScopeV1, kind string) []string { + for _, scope := range scopes { + if scope.GetResourceKind() == kind && slices.Contains(scope.GetActions(), "read") { + return scope.GetResourceIds() + } + } + return nil +} + +// An installed solution serving its own kind attaches through a person's +// capability addressed to it: the installation is the one its seal names. +func TestASolutionAttachesItsOwnKindThroughAPersonsCapability(t *testing.T) { + k := testkit.New(t) + id := ownKindSolution(t, k) + created, err := k.Issuer.Issue(person(t, k, "fixture-alex"), &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "proposals"}) + if err != nil { + t.Fatal(err) + } + got, err := k.Issuer.AttachResource(t.Context(), created.GetToken(), k.Credential(t, "proposals"), "proposals.item", "p1") + if err != nil || got != id { + t.Fatalf("attached to %q (%v), want %s", got, err, id) + } + held, err := k.State.PersonSolutionScopes("maya", "proposals") + if err != nil { + t.Fatal(err) + } + if ids := ownedRead(held, "proposals.item"); !slices.Equal(ids, []string{"p1"}) { + t.Fatalf("the viewer reads %v, want [p1]", ids) + } +} + +// An installation acting for itself attaches what it administers into itself. +func TestAnInstallationAgentAttachesIntoItsInstallation(t *testing.T) { + k := testkit.New(t) + id := ownKindSolution(t, k) + agent, err := k.Issuer.Issue(context.Background(), &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Installation{Installation: &authorityv1.InstallationRef{InstallationId: id}}, + Actor: &authorityv1.WorkloadCredential{Credential: k.Credential(t, "proposals")}, Audience: "documents"}) + if err != nil { + t.Fatal(err) + } + if agent.GetContext().GetOwnerPrincipalKind() != "agent" { + t.Fatalf("owner kind %q", agent.GetContext().GetOwnerPrincipalKind()) + } + got, err := k.Issuer.AttachResource(t.Context(), agent.GetToken(), k.Credential(t, "documents"), "documents.collection", "c-agent") + if err != nil || got != id { + t.Fatalf("attached to %q (%v), want %s", got, err, id) + } + held, err := k.State.PersonSolutionScopes("maya", "proposals") + if err != nil { + t.Fatal(err) + } + if ids := ownedRead(held, "documents.collection"); !slices.Equal(ids, []string{"c-agent"}) { + t.Fatalf("the viewer reads %v, want [c-agent]", ids) + } +} diff --git a/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go index 79115132..e2e70808 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go +++ b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go @@ -93,37 +93,27 @@ func roleResources(scopes []*basev0.WorkScopeV1, kind, action string) []resource return grants } -// AttachResource records that the module serving kind created id under -// token, a capability addressed to that module and holding create on the whole -// kind: attaching is for a resource the installation creates, so a capability -// that only reads, or acts on named ids, attaches nothing. The owning -// installation is read from the capability's verified provenance, never named -// by the caller: an installation owning it, the installation of each solution -// in its actor chain, and the module's own when it is the solution. The state -// requires exactly one, at the capability's authorization revision. +// AttachResource records that id of kind now belongs to the installation the +// presented capability was issued through. The caller is the kind's one +// serving module and the capability is addressed to it, holding either +// create on the whole kind (the module created id) or admin on id itself (an +// administrator of an existing resource attaches it); a capability that only +// reads attaches nothing. The owning installation is read from the +// capability's verified provenance, never named by the caller: an +// installation owning it, the installation of each solution in its actor +// chain, and the module's own when it is the solution. The state requires +// exactly one, at the capability's authorization revision. func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id string) (string, error) { - if kind == "" || id == "" { - return "", fmt.Errorf("%w: an attachment names a resource kind and a resource id", ErrRefused) - } - actor, err := i.state.VerifyWorkload(ctx, credential) + actor, err := i.soleServer(ctx, credential, kind, id) if err != nil { return "", err } - // Only the one module serving a kind is trusted to say what it created; a - // kind two composed modules declare has no such module, so nothing of it - // is attached. - switch servers := i.state.ServersOf(kind); { - case !slices.Contains(servers, actor.Module): - return "", fmt.Errorf("%w: %s does not serve %s, so it attaches none of its resources", ErrRefused, actor.Module, kind) - case len(servers) > 1: - return "", fmt.Errorf("%w: %s is served by %s; an attachment needs the kind's one serving module", ErrRefused, kind, strings.Join(servers, ", ")) - } verified, err := i.verifier(actor.Principal).Verify(ctx, token) if err != nil { return "", err } - if !holdsCreate(verified.EffectiveScopes(), kind) { - return "", fmt.Errorf("%w: the capability holds no create on the whole of %s; an installation attaches only what it creates", ErrRefused, kind) + if !mayAttach(verified.EffectiveScopes(), kind, id) { + return "", fmt.Errorf("%w: the capability holds neither %s on the whole of %s nor %s on %q; reading a resource attaches nothing", ErrRefused, CreateAction, kind, AdminAction, id) } wc := verified.Context() tenant := wc.GetTenantId() @@ -149,15 +139,58 @@ func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id return i.state.AttachResource(tenant, wc.GetAuthorizationRevision(), candidates, kind, id) } -// CreateAction is the action a creating call holds on the whole kind; it is -// what AttachResource requires of the capability it is presented. -const CreateAction = "create" +// DetachResource releases id of kind from whichever installation of org owns +// it, when the kind's one serving module deletes it. Detaching only narrows, +// so it asks the module's word alone; the owner's revision moves, so no +// capability issued before still carries the released id. +func (i *Issuer) DetachResource(ctx context.Context, credential, org, kind, id string) (string, error) { + if _, err := i.soleServer(ctx, credential, kind, id); err != nil { + return "", err + } + if org == "" { + return "", fmt.Errorf("%w: a detachment names the organization the resource belongs to", ErrRefused) + } + return i.state.DetachResource(org, kind, id) +} -// holdsCreate reports whether scopes hold create (or every action) on the -// whole of kind, not on named ids. -func holdsCreate(scopes []*basev0.WorkScopeV1, kind string) bool { +// soleServer authenticates the module and requires it to be the one composed +// module serving kind: only it is trusted with what exists of the kind, and a +// kind two modules declare has no such module, so nothing of it is attached. +func (i *Issuer) soleServer(ctx context.Context, credential, kind, id string) (state.Manifest, error) { + if kind == "" || id == "" { + return state.Manifest{}, fmt.Errorf("%w: an ownership change names a resource kind and a resource id", ErrRefused) + } + actor, err := i.state.VerifyWorkload(ctx, credential) + if err != nil { + return state.Manifest{}, err + } + switch servers := i.state.ServersOf(kind); { + case !slices.Contains(servers, actor.Module): + return state.Manifest{}, fmt.Errorf("%w: %s does not serve %s, so it attaches none of its resources", ErrRefused, actor.Module, kind) + case len(servers) > 1: + return state.Manifest{}, fmt.Errorf("%w: %s is served by %s; an attachment needs the kind's one serving module", ErrRefused, kind, strings.Join(servers, ", ")) + } + return actor, nil +} + +// The actions AttachResource reads, in the serving module's vocabulary: +// create on the whole kind is a creating call; admin on a resource (or on +// the whole kind) administers it. +const ( + CreateAction = "create" + AdminAction = "admin" +) + +// mayAttach reports whether scopes hold create (or every action) on the whole +// of kind, or admin (or every action) on id, by name or on the whole kind. +func mayAttach(scopes []*basev0.WorkScopeV1, kind, id string) bool { return slices.ContainsFunc(scopes, func(scope *basev0.WorkScopeV1) bool { - return scope.GetResourceKind() == kind && len(scope.GetResourceIds()) == 0 && - (slices.Contains(scope.GetActions(), CreateAction) || slices.Contains(scope.GetActions(), state.AnyAction)) + if scope.GetResourceKind() != kind { + return false + } + whole, every := len(scope.GetResourceIds()) == 0, slices.Contains(scope.GetActions(), state.AnyAction) + create := whole && (every || slices.Contains(scope.GetActions(), CreateAction)) + admin := (whole || slices.Contains(scope.GetResourceIds(), id)) && (every || slices.Contains(scope.GetActions(), AdminAction)) + return create || admin }) } diff --git a/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go index 369b363d..d35fad10 100644 --- a/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go +++ b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -74,11 +75,18 @@ func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) candidates []string id string }{ - "no installation": {"acme", revision(), nil, "c1"}, - "two installations": {"acme", revision(), []string{id, other}, "c1"}, - "another organization": {"globex", revision(), []string{id}, "c1"}, - "a superseded revision": {"acme", revision() - 1, []string{id}, "c1"}, - "an id past its length": {"acme", revision(), []string{id}, string(make([]byte, state.MaxResourceIDBytes+1))}, + "no installation": {"acme", revision(), nil, "c1"}, + "two installations": {"acme", revision(), []string{id, other}, "c1"}, + "another organization": {"globex", revision(), []string{id}, "c1"}, + "a superseded revision": {"acme", revision() - 1, []string{id}, "c1"}, + "an id past its length": {"acme", revision(), []string{id}, strings.Repeat("x", state.MaxResourceIDBytes+1)}, + "an empty id": {"acme", revision(), []string{id}, ""}, + "surrounding whitespace": {"acme", revision(), []string{id}, " c1"}, + "a newline": {"acme", revision(), []string{id}, "c\n1"}, + "a NUL": {"acme", revision(), []string{id}, "c\x001"}, + "a tab": {"acme", revision(), []string{id}, "c\t1"}, + "the wildcard": {"acme", revision(), []string{id}, "*"}, + "a wildcard inside": {"acme", revision(), []string{id}, "c*"}, } for name, r := range refusals { if _, err := k.State.AttachResource(r.tenant, r.revision, r.candidates, "documents.collection", r.id); !errors.Is(err, state.ErrForbidden) { @@ -108,6 +116,21 @@ func TestAnInstallationOwnsOnlyWhatWasAttachedSinceItWasInstalled(t *testing.T) if _, err := k.State.AttachResource("acme", revision(), []string{id}, "documents.collection", "one-too-many"); !errors.Is(err, state.ErrForbidden) { t.Fatalf("past the bound a capability carries: %v", err) } + // The bound counts what is owned now: a detached id frees its place, and + // detaching narrows, so a revision from before it is superseded. + beforeDetach := revision() + if from, err := k.State.DetachResource("acme", "documents.collection", "c2"); err != nil || from != id { + t.Fatalf("detach: %q %v", from, err) + } + if _, err := k.State.AttachResource("acme", beforeDetach, []string{id}, "documents.collection", "one-too-many"); !errors.Is(err, state.ErrForbidden) { + t.Fatalf("a capability from before a detachment is superseded: %v", err) + } + if _, err := k.State.AttachResource("acme", revision(), []string{id}, "documents.collection", "one-too-many"); err != nil { + t.Fatalf("a detached id frees its place: %v", err) + } + if from, err := k.State.DetachResource("acme", "documents.collection", "nobody-owns-this"); err != nil || from != "" { + t.Fatalf("detaching an unowned id: %q %v", from, err) + } // A capability minted before an uninstall never attaches into the // reinstalled installation, though its id is the same. minted := revision() diff --git a/modules/authority/services/local/code/pkg/business/state/solution_roles.go b/modules/authority/services/local/code/pkg/business/state/solution_roles.go index 5a808967..3aabb64d 100644 --- a/modules/authority/services/local/code/pkg/business/state/solution_roles.go +++ b/modules/authority/services/local/code/pkg/business/state/solution_roles.go @@ -72,19 +72,17 @@ const ( MaxResourceIDBytes = 128 ) -// AttachResource records that id of kind was created through one -// installation. candidates are the installations the presenting capability's +// AttachResource records that id of kind now belongs to one installation. candidates are the installations the presenting capability's // verified provenance names, and revision is the authorization revision it was // minted at; exactly one active installation at tenant must remain, at the // tenant's current revision, checked under the write lock so a capability from // before an uninstall never attaches into the reinstalled installation. A // resource has one owner in a tenant: an id another installation owns is -// refused, since attaching is for what an installation creates, never for an -// existing resource. Attaching only widens what an installation-owned scope +// refused until its serving module detaches it. Attaching only widens what an installation-owned scope // covers, so no revision moves: every capability already issued names its ids. func (s *State) AttachResource(tenant string, revision uint64, candidates []string, kind, id string) (string, error) { - if len(id) > MaxResourceIDBytes { - return "", fmt.Errorf("%w: resource id is %d bytes; an installation-owned id is at most %d", ErrForbidden, len(id), MaxResourceIDBytes) + if err := validResourceID(id); err != nil { + return "", err } candidates = slices.Compact(slices.Sorted(slices.Values(candidates))) switch len(candidates) { @@ -108,7 +106,7 @@ func (s *State) AttachResource(tenant string, revision uint64, candidates []stri } for _, other := range s.installations { if other.ID != inst.ID && other.Organization == tenant && slices.Contains(other.Owned[kind], id) { - return "", fmt.Errorf("%w: %s %q is owned by installation %s; an installation attaches only what it creates", ErrForbidden, kind, id, other.ID) + return "", fmt.Errorf("%w: %s %q is owned by installation %s; a resource has one owner in an organisation", ErrForbidden, kind, id, other.ID) } } if len(inst.Owned[kind]) >= MaxOwnedPerKind { @@ -123,3 +121,44 @@ func (s *State) AttachResource(tenant string, revision uint64, candidates []stri s.installations[inst.ID] = inst return inst.ID, nil } + +// DetachResource releases id of kind from whichever installation of tenant +// owns it, answering that installation, or nothing when none does. Releasing +// narrows what the owner's roles grant, so its revision and the tenant's move: +// a capability issued before never carries the released id on. +func (s *State) DetachResource(tenant, kind, id string) (string, error) { + if err := validResourceID(id); err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.orgs[tenant]; !ok { + return "", fmt.Errorf("%w: organization %q", ErrUnknown, tenant) + } + for _, inst := range s.installations { + if inst.Organization != tenant || !slices.Contains(inst.Owned[kind], id) { + continue + } + owned := maps.Clone(inst.Owned) + owned[kind] = slices.DeleteFunc(slices.Clone(owned[kind]), func(held string) bool { return held == id }) + inst.Owned = owned + inst.Revision++ + s.installations[inst.ID] = inst + s.revisions[tenant]++ + return inst.ID, nil + } + return "", nil +} + +// validResourceID holds an owned id to core's scope-value rule (non-empty, +// no surrounding whitespace, no NUL, CR, LF or tab), refuses the wildcard +// character, and bounds its length. Ids are opaque and compared byte for byte. +func validResourceID(id string) error { + switch { + case id == "" || strings.TrimSpace(id) != id || strings.ContainsAny(id, "\x00\r\n\t*"): + return fmt.Errorf("%w: resource id %q is not a usable id (empty, surrounding whitespace, or a NUL, CR, LF, tab or *)", ErrForbidden, id) + case len(id) > MaxResourceIDBytes: + return fmt.Errorf("%w: resource id is %d bytes; an installation-owned id is at most %d", ErrForbidden, len(id), MaxResourceIDBytes) + } + return nil +} diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go index d1bf36f7..00942b76 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go @@ -1336,6 +1336,125 @@ func (x *AttachResourceResponse) GetInstallationId() string { return "" } +// DetachResourceRequest names one resource its serving module deleted. +type DetachResourceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The module serving resource_kind, proving which module it is. + Actor *WorkloadCredential `protobuf:"bytes,1,opt,name=actor,proto3" json:"actor,omitempty"` + // The organisation the resource belongs to. + OrganizationId string `protobuf:"bytes,2,opt,name=organization_id,json=organizationId,proto3" json:"organization_id,omitempty"` + // The resource kind, as the caller serves it. + ResourceKind string `protobuf:"bytes,3,opt,name=resource_kind,json=resourceKind,proto3" json:"resource_kind,omitempty"` + // The resource id, as the caller knows it. + ResourceId string `protobuf:"bytes,4,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachResourceRequest) Reset() { + *x = DetachResourceRequest{} + mi := &file_host_authority_v1_authority_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachResourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachResourceRequest) ProtoMessage() {} + +func (x *DetachResourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DetachResourceRequest.ProtoReflect.Descriptor instead. +func (*DetachResourceRequest) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} +} + +func (x *DetachResourceRequest) GetActor() *WorkloadCredential { + if x != nil { + return x.Actor + } + return nil +} + +func (x *DetachResourceRequest) GetOrganizationId() string { + if x != nil { + return x.OrganizationId + } + return "" +} + +func (x *DetachResourceRequest) GetResourceKind() string { + if x != nil { + return x.ResourceKind + } + return "" +} + +func (x *DetachResourceRequest) GetResourceId() string { + if x != nil { + return x.ResourceId + } + return "" +} + +// DetachResourceResponse names the installation that owned the resource. +type DetachResourceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The installation released from it; empty when none owned it. + InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachResourceResponse) Reset() { + *x = DetachResourceResponse{} + mi := &file_host_authority_v1_authority_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachResourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachResourceResponse) ProtoMessage() {} + +func (x *DetachResourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_host_authority_v1_authority_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DetachResourceResponse.ProtoReflect.Descriptor instead. +func (*DetachResourceResponse) Descriptor() ([]byte, []int) { + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{21} +} + +func (x *DetachResourceResponse) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + // ResourceGrant is one way of naming covered resources. Exactly one is set. type ResourceGrant struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1352,7 +1471,7 @@ type ResourceGrant struct { func (x *ResourceGrant) Reset() { *x = ResourceGrant{} - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1364,7 +1483,7 @@ func (x *ResourceGrant) String() string { func (*ResourceGrant) ProtoMessage() {} func (x *ResourceGrant) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[20] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1377,7 +1496,7 @@ func (x *ResourceGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceGrant.ProtoReflect.Descriptor instead. func (*ResourceGrant) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{20} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1547,19 +1666,28 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\vresource_id\x18\x04 \x01(\tR\n" + "resourceId\"A\n" + "\x16AttachResourceResponse\x12'\n" + + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n" + + "\x15DetachResourceRequest\x12;\n" + + "\x05actor\x18\x01 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12'\n" + + "\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n" + + "\rresource_kind\x18\x03 \x01(\tR\fresourceKind\x12\x1f\n" + + "\vresource_id\x18\x04 \x01(\tR\n" + + "resourceId\"A\n" + + "\x16DetachResourceResponse\x12'\n" + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"t\n" + "\rResourceGrant\x12\x12\n" + "\x03all\x18\x01 \x01(\bH\x00R\x03all\x12\x10\n" + "\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apatternB\a\n" + - "\x05match2\x93\x06\n" + + "\x05match2\xfa\x06\n" + "\x10AuthorityService\x12j\n" + "\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:issue\x12j\n" + "\x05Check\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/v1/authority:check\x12G\n" + "\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12b\n" + "\rListResources\x12'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12e\n" + - "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12M\n" + + "\x0eAttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12e\n" + + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12M\n" + "\bDelegate\x12\".host.authority.v1.DelegateRequest\x1a\x1d.host.authority.v1.Delegation\x12k\n" + "\x10RevokeDelegation\x12*.host.authority.v1.RevokeDelegationRequest\x1a+.host.authority.v1.RevokeDelegationResponse\x12W\n" + "\rGetDelegation\x12'.host.authority.v1.GetDelegationRequest\x1a\x1d.host.authority.v1.DelegationB/Z-gateway/pkg/gen/host/authority/v1;authorityv1b\x06proto3" @@ -1576,7 +1704,7 @@ func file_host_authority_v1_authority_proto_rawDescGZIP() []byte { return file_host_authority_v1_authority_proto_rawDescData } -var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_host_authority_v1_authority_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_host_authority_v1_authority_proto_goTypes = []any{ (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef @@ -1598,47 +1726,52 @@ var file_host_authority_v1_authority_proto_goTypes = []any{ (*ListResourcesResponse)(nil), // 17: host.authority.v1.ListResourcesResponse (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse - (*ResourceGrant)(nil), // 20: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 21: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 22: host.authority.v1.WorkContextV1 + (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse + (*ResourceGrant)(nil), // 22: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 23: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 24: host.authority.v1.WorkContextV1 } var file_host_authority_v1_authority_proto_depIdxs = []int32{ 7, // 0: host.authority.v1.IssueRequest.person:type_name -> host.authority.v1.PersonSession 8, // 1: host.authority.v1.IssueRequest.workload:type_name -> host.authority.v1.WorkloadCredential 9, // 2: host.authority.v1.IssueRequest.installation:type_name -> host.authority.v1.InstallationRef 1, // 3: host.authority.v1.IssueRequest.delegation:type_name -> host.authority.v1.DelegationRef - 21, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 23, // 4: host.authority.v1.IssueRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 5: host.authority.v1.IssueRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 21, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 - 21, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 + 23, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 23, // 7: host.authority.v1.Delegation.scopes:type_name -> host.authority.v1.WorkScopeV1 8, // 8: host.authority.v1.GetDelegationRequest.actor:type_name -> host.authority.v1.WorkloadCredential 3, // 9: host.authority.v1.RevokeDelegationResponse.delegation:type_name -> host.authority.v1.Delegation - 22, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 - 22, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 + 24, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 24, // 11: host.authority.v1.CheckResponse.context:type_name -> host.authority.v1.WorkContextV1 15, // 12: host.authority.v1.KeysResponse.keys:type_name -> host.authority.v1.PublicKey - 20, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant + 22, // 13: host.authority.v1.ListResourcesResponse.grants:type_name -> host.authority.v1.ResourceGrant 8, // 14: host.authority.v1.AttachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential - 0, // 15: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest - 11, // 16: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest - 13, // 17: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest - 16, // 18: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest - 18, // 19: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest - 2, // 20: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest - 5, // 21: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest - 4, // 22: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest - 10, // 23: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse - 12, // 24: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse - 14, // 25: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse - 17, // 26: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse - 19, // 27: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse - 3, // 28: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation - 6, // 29: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse - 3, // 30: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation - 23, // [23:31] is the sub-list for method output_type - 15, // [15:23] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 16: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 17: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 18: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 19: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 20: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 20, // 21: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 2, // 22: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 23: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 24: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 25: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 26: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 27: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 28: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 29: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 21, // 30: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 3, // 31: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 32: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 33: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 25, // [25:34] is the sub-list for method output_type + 16, // [16:25] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1654,7 +1787,7 @@ func file_host_authority_v1_authority_proto_init() { (*IssueRequest_ParentToken)(nil), (*IssueRequest_Delegation)(nil), } - file_host_authority_v1_authority_proto_msgTypes[20].OneofWrappers = []any{ + file_host_authority_v1_authority_proto_msgTypes[22].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1666,7 +1799,7 @@ func file_host_authority_v1_authority_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_authority_v1_authority_proto_rawDesc), len(file_host_authority_v1_authority_proto_rawDesc)), NumEnums: 0, - NumMessages: 21, + NumMessages: 23, NumExtensions: 0, NumServices: 1, }, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index 631f374f..c084d542 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -31,6 +31,7 @@ const ( AuthorityService_Keys_FullMethodName = "/host.authority.v1.AuthorityService/Keys" AuthorityService_ListResources_FullMethodName = "/host.authority.v1.AuthorityService/ListResources" AuthorityService_AttachResource_FullMethodName = "/host.authority.v1.AuthorityService/AttachResource" + AuthorityService_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" AuthorityService_GetDelegation_FullMethodName = "/host.authority.v1.AuthorityService/GetDelegation" @@ -58,25 +59,36 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. AttachResource(ctx context.Context, in *AttachResourceRequest, opts ...grpc.CallOption) (*AttachResourceResponse, error) + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + DetachResource(ctx context.Context, in *DetachResourceRequest, opts ...grpc.CallOption) (*DetachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) @@ -147,6 +159,16 @@ func (c *authorityServiceClient) AttachResource(ctx context.Context, in *AttachR return out, nil } +func (c *authorityServiceClient) DetachResource(ctx context.Context, in *DetachResourceRequest, opts ...grpc.CallOption) (*DetachResourceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DetachResourceResponse) + err := c.cc.Invoke(ctx, AuthorityService_DetachResource_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorityServiceClient) Delegate(ctx context.Context, in *DelegateRequest, opts ...grpc.CallOption) (*Delegation, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Delegation) @@ -199,25 +221,36 @@ type AuthorityServiceServer interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + DetachResource(context.Context, *DetachResourceRequest) (*DetachResourceResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *DelegateRequest) (*Delegation, error) @@ -253,6 +286,9 @@ func (UnimplementedAuthorityServiceServer) ListResources(context.Context, *ListR func (UnimplementedAuthorityServiceServer) AttachResource(context.Context, *AttachResourceRequest) (*AttachResourceResponse, error) { return nil, status.Error(codes.Unimplemented, "method AttachResource not implemented") } +func (UnimplementedAuthorityServiceServer) DetachResource(context.Context, *DetachResourceRequest) (*DetachResourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DetachResource not implemented") +} func (UnimplementedAuthorityServiceServer) Delegate(context.Context, *DelegateRequest) (*Delegation, error) { return nil, status.Error(codes.Unimplemented, "method Delegate not implemented") } @@ -373,6 +409,24 @@ func _AuthorityService_AttachResource_Handler(srv interface{}, ctx context.Conte return interceptor(ctx, in, info, handler) } +func _AuthorityService_DetachResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DetachResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorityServiceServer).DetachResource(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorityService_DetachResource_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorityServiceServer).DetachResource(ctx, req.(*DetachResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorityService_Delegate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DelegateRequest) if err := dec(in); err != nil { @@ -454,6 +508,10 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AttachResource", Handler: _AuthorityService_AttachResource_Handler, }, + { + MethodName: "DetachResource", + Handler: _AuthorityService_DetachResource_Handler, + }, { MethodName: "Delegate", Handler: _AuthorityService_Delegate_Handler, diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index f47aa30a..86f53dc3 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -51,6 +51,9 @@ const ( // AuthorityServiceAttachResourceProcedure is the fully-qualified name of the AuthorityService's // AttachResource RPC. AuthorityServiceAttachResourceProcedure = "/host.authority.v1.AuthorityService/AttachResource" + // AuthorityServiceDetachResourceProcedure is the fully-qualified name of the AuthorityService's + // DetachResource RPC. + AuthorityServiceDetachResourceProcedure = "/host.authority.v1.AuthorityService/DetachResource" // AuthorityServiceDelegateProcedure is the fully-qualified name of the AuthorityService's Delegate // RPC. AuthorityServiceDelegateProcedure = "/host.authority.v1.AuthorityService/Delegate" @@ -80,25 +83,36 @@ type AuthorityServiceClient interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + DetachResource(context.Context, *connect.Request[v1.DetachResourceRequest]) (*connect.Response[v1.DetachResourceResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) @@ -152,6 +166,12 @@ func NewAuthorityServiceClient(httpClient connect.HTTPClient, baseURL string, op connect.WithSchema(authorityServiceMethods.ByName("AttachResource")), connect.WithClientOptions(opts...), ), + detachResource: connect.NewClient[v1.DetachResourceRequest, v1.DetachResourceResponse]( + httpClient, + baseURL+AuthorityServiceDetachResourceProcedure, + connect.WithSchema(authorityServiceMethods.ByName("DetachResource")), + connect.WithClientOptions(opts...), + ), delegate: connect.NewClient[v1.DelegateRequest, v1.Delegation]( httpClient, baseURL+AuthorityServiceDelegateProcedure, @@ -180,6 +200,7 @@ type authorityServiceClient struct { keys *connect.Client[v1.KeysRequest, v1.KeysResponse] listResources *connect.Client[v1.ListResourcesRequest, v1.ListResourcesResponse] attachResource *connect.Client[v1.AttachResourceRequest, v1.AttachResourceResponse] + detachResource *connect.Client[v1.DetachResourceRequest, v1.DetachResourceResponse] delegate *connect.Client[v1.DelegateRequest, v1.Delegation] revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] getDelegation *connect.Client[v1.GetDelegationRequest, v1.Delegation] @@ -210,6 +231,11 @@ func (c *authorityServiceClient) AttachResource(ctx context.Context, req *connec return c.attachResource.CallUnary(ctx, req) } +// DetachResource calls host.authority.v1.AuthorityService.DetachResource. +func (c *authorityServiceClient) DetachResource(ctx context.Context, req *connect.Request[v1.DetachResourceRequest]) (*connect.Response[v1.DetachResourceResponse], error) { + return c.detachResource.CallUnary(ctx, req) +} + // Delegate calls host.authority.v1.AuthorityService.Delegate. func (c *authorityServiceClient) Delegate(ctx context.Context, req *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) { return c.delegate.CallUnary(ctx, req) @@ -243,25 +269,36 @@ type AuthorityServiceHandler interface { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. ListResources(context.Context, *connect.Request[v1.ListResourcesRequest]) (*connect.Response[v1.ListResourcesResponse], error) - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. AttachResource(context.Context, *connect.Request[v1.AttachResourceRequest]) (*connect.Response[v1.AttachResourceResponse], error) + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + DetachResource(context.Context, *connect.Request[v1.DetachResourceRequest]) (*connect.Response[v1.DetachResourceResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. Delegate(context.Context, *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) @@ -311,6 +348,12 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han connect.WithSchema(authorityServiceMethods.ByName("AttachResource")), connect.WithHandlerOptions(opts...), ) + authorityServiceDetachResourceHandler := connect.NewUnaryHandler( + AuthorityServiceDetachResourceProcedure, + svc.DetachResource, + connect.WithSchema(authorityServiceMethods.ByName("DetachResource")), + connect.WithHandlerOptions(opts...), + ) authorityServiceDelegateHandler := connect.NewUnaryHandler( AuthorityServiceDelegateProcedure, svc.Delegate, @@ -341,6 +384,8 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han authorityServiceListResourcesHandler.ServeHTTP(w, r) case AuthorityServiceAttachResourceProcedure: authorityServiceAttachResourceHandler.ServeHTTP(w, r) + case AuthorityServiceDetachResourceProcedure: + authorityServiceDetachResourceHandler.ServeHTTP(w, r) case AuthorityServiceDelegateProcedure: authorityServiceDelegateHandler.ServeHTTP(w, r) case AuthorityServiceRevokeDelegationProcedure: @@ -376,6 +421,10 @@ func (UnimplementedAuthorityServiceHandler) AttachResource(context.Context, *con return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.AttachResource is not implemented")) } +func (UnimplementedAuthorityServiceHandler) DetachResource(context.Context, *connect.Request[v1.DetachResourceRequest]) (*connect.Response[v1.DetachResourceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.DetachResource is not implemented")) +} + func (UnimplementedAuthorityServiceHandler) Delegate(context.Context, *connect.Request[v1.DelegateRequest]) (*connect.Response[v1.Delegation], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.Delegate is not implemented")) } diff --git a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go index a1286a31..66838ede 100644 --- a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go @@ -516,7 +516,8 @@ type RoleV1 struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` // Scopes the role grants only on the resources the installation owns: those - // newly created through it (AuthorityService.AttachResource). Each + // created through it or attached by its administrator + // (AuthorityService.AttachResource). Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/modules/authority/services/local/code/plugins/authority/plugin.go b/modules/authority/services/local/code/plugins/authority/plugin.go index 1cde4d55..618c88ab 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -194,6 +194,15 @@ func (p *Plugin) AttachResource(ctx context.Context, req *authorityv1.AttachReso return &authorityv1.AttachResourceResponse{InstallationId: id}, nil } +// DetachResource releases a resource its serving module deleted. +func (p *Plugin) DetachResource(ctx context.Context, req *authorityv1.DetachResourceRequest) (*authorityv1.DetachResourceResponse, error) { + id, err := p.Issuer.DetachResource(ctx, req.GetActor().GetCredential(), req.GetOrganizationId(), req.GetResourceKind(), req.GetResourceId()) + if err != nil { + return nil, Status(err) + } + return &authorityv1.DetachResourceResponse{InstallationId: id}, nil +} + // Keys returns the issuer and its keys. func (p *Plugin) Keys(context.Context, *authorityv1.KeysRequest) (*authorityv1.KeysResponse, error) { return p.Issuer.Keys(), nil diff --git a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go index 0a6a5abf..ebf7fec2 100644 --- a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go @@ -144,7 +144,7 @@ people: A: conformance.OwnedInstallation{Solution: "conformance-owner-a", Credential: k.Credential(t, "conformance-owner-a"), Installation: owned["conformance-owner-a"]}, B: conformance.OwnedInstallation{Solution: "conformance-owner-b", Credential: k.Credential(t, "conformance-owner-b"), Installation: owned["conformance-owner-b"]}, Store: "conformance-store", StoreCredential: k.Credential(t, "conformance-store"), Kind: "conformance.collection", - ResourceA: "collection-a", ResourceA2: "collection-a2", ResourceB: "collection-b", + ResourceA: "collection-a", ResourceA2: "collection-a2", ResourceB: "collection-b", ResourceX: "collection-x", ResourceY: "collection-y", }, WorkloadOrganizations: &conformance.WorkloadOrganizationsCase{ Ambiguous: conformance.WorkloadOrganizationCase{Credential: k.Credential(t, "org-directory"), Module: "org-directory", Binding: "records", Audience: "records"}, diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index 976fb476..b39fedc5 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -33,25 +33,36 @@ service AuthorityService { // rather than carried in the capability. A module maps the answer onto its // own resources; the authority knows them only by id. rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse); - // AttachResource records that one resource was newly created through an - // installation, so a role scope its solution declares installation-owned - // (RoleV1.installation_owned_scopes) covers it. It is for what the - // installation creates, never for an existing resource: a resource has one - // owner in an organisation, and an id another installation owns is refused. - // The caller is the kind's one serving module (a kind two composed modules - // declare attaches nothing), proving itself with its workload credential, - // and presents the capability the creating call carried: addressed to it, at - // the organisation's current authorization revision, and holding create on - // the whole kind; a capability that only reads, or acts on named ids, - // attaches nothing. The owning installation is read from that capability's + // AttachResource records that one resource now belongs to an installation, + // so a role scope its solution declares installation-owned + // (RoleV1.installation_owned_scopes) covers it. The caller is the kind's one + // serving module (a kind two composed modules declare attaches nothing), + // proving itself with its workload credential, and presents a capability + // addressed to it, at the organisation's current authorization revision, + // holding either the action "create" on the whole kind (the call that created + // the resource) or the action "admin" on that resource id, by name or on the + // whole kind (an administrator attaching an existing resource, as an + // installer configuring a solution's collection). A capability that only + // reads attaches nothing. A resource has one owner in an organisation: an id + // another installation owns is refused. A store whose attach is refused fails + // or rolls back the create it made, and detaches what it deletes + // (DetachResource). The owning installation is read from the capability's // verified provenance and never named by the caller: the installation itself // when it is the owner, the installed solution when it is the caller, and - // each solution in its actor chain; exactly one must result. Owned ids travel - // in every capability holding them, so an installation owns at most 32 of one - // kind, each id at most 128 bytes, refused by name past either. Attaching - // only widens what an owned scope covers, so it moves no authorization - // revision; a reinstalled installation owns nothing. + // each solution in its actor chain; exactly one must result. Ids are opaque + // and compared byte for byte; an id is refused when it is empty, carries + // surrounding whitespace, a NUL, CR, LF, tab or "*", or exceeds 128 bytes. + // Owned ids travel in every capability holding them, so an installation owns + // at most 32 of one kind at a time, refused by name past it. Attaching only + // widens what an owned scope covers, so it moves no authorization revision; + // a reinstalled installation owns nothing. rpc AttachResource(AttachResourceRequest) returns (AttachResourceResponse); + // DetachResource releases one resource from whichever installation of the + // organisation owns it, when the kind's one serving module deletes it, so the + // id no longer counts toward the owner's bound and may be attached again. + // It narrows the owner's roles, so the organisation's authorization revision + // moves. Detaching an id no installation owns answers no installation. + rpc DetachResource(DetachResourceRequest) returns (DetachResourceResponse); // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. rpc Delegate(DelegateRequest) returns (Delegation); @@ -281,6 +292,24 @@ message AttachResourceResponse { string installation_id = 1; } +// DetachResourceRequest names one resource its serving module deleted. +message DetachResourceRequest { + // The module serving resource_kind, proving which module it is. + WorkloadCredential actor = 1; + // The organisation the resource belongs to. + string organization_id = 2; + // The resource kind, as the caller serves it. + string resource_kind = 3; + // The resource id, as the caller knows it. + string resource_id = 4; +} + +// DetachResourceResponse names the installation that owned the resource. +message DetachResourceResponse { + // The installation released from it; empty when none owned it. + string installation_id = 1; +} + // ResourceGrant is one way of naming covered resources. Exactly one is set. message ResourceGrant { oneof match { diff --git a/modules/authority/services/local/proto/host/composition/v1/composition.proto b/modules/authority/services/local/proto/host/composition/v1/composition.proto index f166f064..032bf614 100644 --- a/modules/authority/services/local/proto/host/composition/v1/composition.proto +++ b/modules/authority/services/local/proto/host/composition/v1/composition.proto @@ -122,7 +122,8 @@ message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; // Scopes the role grants only on the resources the installation owns: those - // newly created through it (AuthorityService.AttachResource). Each + // created through it or attached by its administrator + // (AuthorityService.AttachResource). Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same From 337ab23edd05ca555a4aae1dceca220fd4c12636 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:13:26 -0400 Subject: [PATCH 4/4] fix: authorize existing resource ownership through transfer Keep ordinary solution scopes owner-selected; record transferred ids only after the existing live policy succeeds, and retain create-only attachment. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- .../contract/catalog.codefly.json | 2 +- .../authority-client/contract/contract.binpb | Bin 388265 -> 388804 bytes .../host/authority/v1/authority_grpc.pb.go | 40 ++++-- .../gen/host/composition/v1/composition.pb.go | 4 +- .../authority-client/library.codefly.yaml | 2 +- .../host/authority/v1/authority_pb2_grpc.py | 20 ++- .../src/gen/host/authority/v1/authority_pb.ts | 20 ++- .../gen/host/composition/v1/composition_pb.ts | 4 +- .../authority-core/go/authority/attachment.go | 136 ++++++++++-------- .../go/authority/attachment_test.go | 125 ++++++++++++++-- .../authority-core/go/authority/transfer.go | 60 +++++--- libraries/conformance/go/README.md | 2 +- .../conformance/go/installation_resources.go | 60 ++++---- .../code/pkg/business/issuer/attach_test.go | 22 +-- .../host/authority/v1/authority_grpc.pb.go | 40 ++++-- .../authorityv1connect/authority.connect.go | 40 ++++-- .../gen/host/composition/v1/composition.pb.go | 4 +- .../registry_test/local_authority_test.go | 18 +++ .../proto/host/authority/v1/authority.proto | 20 ++- .../host/composition/v1/composition.proto | 4 +- tools/noproxygate/generated.json | 10 +- 22 files changed, 435 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index 9e323566..87d3e118 100644 --- a/README.md +++ b/README.md @@ -961,8 +961,15 @@ must adopt the version and fields before claiming this contract. `codefly.dev/composition@0.2.0` adds `RoleV1.installation_owned_scopes`: a role scope held only on the resources its installation owns. The kind's one serving module attaches a resource from a capability holding `create` on the whole kind -(it created it) or `admin` on that id (an administrator attaches an existing -resource); reading attaches nothing. A resource has one owner per organisation, +for a newly created resource. Existing resources change owner through +`AuthorizeTransfer`: the serving module attests the current owner and presents +a person's capability through the target installation. The existing policy +requires that person's live organization admin grant on the resource and write +through their target role, and refuses foreign owners, solution-principal +owners, agents, missing grants and wrong provenance by name. Only after those +checks succeed does the same transaction record the owned id. Admin-only +`AttachResource` is refused with `AuthorizeTransfer` named; ordinary solution +roles always retain their resource-owner selector. A resource has one owner per organisation, an installation owns at most 32 of a kind at a time, and the serving module detaches what it deletes and rolls back a create whose attach is refused. Both are additive. The local authority implements authority 0.3.1 and composition 0.2.0; the edge, hop and @@ -973,7 +980,7 @@ a host must implement both before it passes. The local authority keeps installation ownership in memory, for development only: a restart (a hot reload included) re-derives installations from the fixture but not what they own, so viewers lose owned resources until the store -attaches them again. A host keeps ownership durably. +reconciles them through the authorized creation or transfer path. A host keeps ownership durably. The installation-owned resolver and ownership transactions live in `libraries/authority-core/go`, alongside the ordinary role resolver. Hosts diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index d8872777..f7d10e56 100644 --- a/libraries/authority-client/contract/catalog.codefly.json +++ b/libraries/authority-client/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "api", "path": "contract.binpb", - "digest": "sha256:6e1e9190366c45e6b7d85371e54ba50c0b7ec1e6707b845334178fb9f1d075cb", + "digest": "sha256:c991396ffbfdc2ec53c75e6dd9ea244a15951ad78bb0a99cf00e24a788cb9f74", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index c4e61c2d562a1b34da598eeaf7d8ea57970cbe4e..c5f42926799a89ac11eb26ab5372d83ee74c5453 100644 GIT binary patch delta 5860 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 & { /** * Scopes the role grants only on the resources the installation owns: those - * created through it or attached by its administrator - * (AuthorityService.AttachResource). Each + * created through it (AuthorityService.AttachResource) or transferred after + * AuthorityService.AuthorizeTransfer authorizes the change of owner. Each * names a kind and actions and no resource ids, since ids are per * installation, and is held to the same ceiling as scopes. The authority * resolves each, for the installation the role is held on, into the same diff --git a/libraries/authority-core/go/authority/attachment.go b/libraries/authority-core/go/authority/attachment.go index 6904114c..5663e01f 100644 --- a/libraries/authority-core/go/authority/attachment.go +++ b/libraries/authority-core/go/authority/attachment.go @@ -38,8 +38,8 @@ func (s *Service) AttachResource(ctx context.Context, r AttachmentRequest) (stri if err != nil { return "", err } - if !mayAttach(scope.Unowned(verified.EffectiveScopes()), r.Kind, r.ResourceID, "") { - return "", domain.Refuse("the capability holds neither create on the whole of %s nor admin on %q; reading a resource attaches nothing", r.Kind, r.ResourceID) + if !mayAttach(scope.Unowned(verified.EffectiveScopes()), r.Kind, "") { + return "", domain.Refuse("the capability holds no create on the whole of %s; existing resources require AuthorizeTransfer", r.Kind) } wc := verified.Context() var owner string @@ -52,30 +52,7 @@ func (s *Service) AttachResource(ctx context.Context, r AttachmentRequest) (stri if current.Principal != actor.Principal || current.Generation != actor.Generation { return domain.Refuse("serving module changed during attachment") } - revision, err := tx.AuthorizationRevision(wc.GetTenantId()) - if err != nil { - return err - } - if revision != wc.GetAuthorizationRevision() { - return domain.Refuse("authorization revision superseded during attachment") - } - // Recheck the owner and every actor's seal/epoch inside the write snapshot. - check := func(principal string, sealID string, revision, epoch uint64) error { - seal, err := s.src.sealIn(tx, principal, sealID) - if err != nil { - return err - } - liveEpoch, err := s.src.epochIn(tx, principal) - if err != nil { - return err - } - if seal.InstallationRevision != revision || liveEpoch != epoch { - return domain.Refuse("capability provenance superseded during attachment") - } - return nil - } - seal := wc.GetSeal() - if err := check(wc.GetOwnerPrincipalId(), seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetPrincipalEpoch()); err != nil { + if err := s.attachmentFresh(tx, wc); err != nil { return err } candidates := []string{} @@ -84,15 +61,6 @@ func (s *Service) AttachResource(ctx context.Context, r AttachmentRequest) (stri } else if !errors.Is(err, domain.ErrNotFound) { return err } - for _, hop := range wc.GetActorChain() { - epoch, err := s.src.epochIn(tx, hop.GetPrincipalId()) - if err != nil { - return err - } - if epoch != hop.GetPrincipalEpoch() { - return domain.Refuse("actor provenance superseded during attachment") - } - } for _, principal := range solutionPrincipals(wc, actor.Principal) { m, err := tx.Module(principal) if err != nil { @@ -121,31 +89,12 @@ func (s *Service) AttachResource(ctx context.Context, r AttachmentRequest) (stri if inst.Organization != wc.GetTenantId() || inst.Status != domain.Active { return domain.Refuse("installation %s is not active at %s", inst.ID, wc.GetTenantId()) } - if !mayAttach(verified.EffectiveScopes(), r.Kind, r.ResourceID, inst.PrincipalID) { - return domain.Refuse("the capability holds neither create for this installation nor owner-free admin on %q; an owner-selected admin scope does not attest the resource's owner", r.ResourceID) + if !mayAttach(verified.EffectiveScopes(), r.Kind, inst.PrincipalID) { + return domain.Refuse("the capability holds no create for this installation; existing resources require AuthorizeTransfer") } - all, err := tx.Installations(inst.Organization) - if err != nil { + if err := attachOwned(tx, inst, r.Kind, r.ResourceID); err != nil { return err } - for _, other := range all { - if other.ID != inst.ID && slices.Contains(other.Owned[r.Kind], r.ResourceID) { - return domain.Refuse("%s %q is owned by installation %s; a resource has one owner in an organisation", r.Kind, r.ResourceID, other.ID) - } - } - if !slices.Contains(inst.Owned[r.Kind], r.ResourceID) { - if len(inst.Owned[r.Kind]) >= MaxOwnedPerKind { - return domain.Refuse("installation %s already owns %d of %s, the most a capability carries", inst.ID, MaxOwnedPerKind, r.Kind) - } - if inst.Owned == nil { - inst.Owned = map[string][]string{} - } - inst.Owned[r.Kind] = append(inst.Owned[r.Kind], r.ResourceID) - slices.Sort(inst.Owned[r.Kind]) - if err := tx.PutInstallation(inst); err != nil { - return err - } - } owner = inst.ID return nil }) @@ -213,13 +162,80 @@ func attachmentServer(tx Tx, caller Workload, kind string) (domain.Module, error return m, nil } -func mayAttach(scopes Scopes, kind, id, installationPrincipal string) bool { +func mayAttach(scopes Scopes, kind, installationPrincipal string) bool { return slices.ContainsFunc(scopes, func(sc *basev0.WorkScopeV1) bool { if sc.GetResourceKind() != kind { return false } whole, every := len(sc.GetResourceIds()) == 0, slices.Contains(sc.GetActions(), scope.AnyAction) - return (whole && (sc.GetResourceOwnerId() == "" || sc.GetResourceOwnerId() == installationPrincipal) && (every || slices.Contains(sc.GetActions(), "create"))) || - (sc.GetResourceOwnerId() == "" && (whole || slices.Contains(sc.GetResourceIds(), id)) && (every || slices.Contains(sc.GetActions(), "admin"))) + return whole && (sc.GetResourceOwnerId() == "" || sc.GetResourceOwnerId() == installationPrincipal) && (every || slices.Contains(sc.GetActions(), "create")) }) } + +// attachmentFresh fences verification against changes before the write snapshot. +func (s *Service) attachmentFresh(tx Tx, wc *basev0.WorkContextV1) error { + revision, err := tx.AuthorizationRevision(wc.GetTenantId()) + if err != nil { + return err + } + if revision != wc.GetAuthorizationRevision() { + return domain.Refuse("authorization revision superseded during attachment") + } + // Recheck the owner and every actor's seal/epoch inside the write snapshot. + check := func(principal string, sealID string, revision, epoch uint64) error { + seal, err := s.src.sealIn(tx, principal, sealID) + if err != nil { + return err + } + liveEpoch, err := s.src.epochIn(tx, principal) + if err != nil { + return err + } + if seal.InstallationRevision != revision || liveEpoch != epoch { + return domain.Refuse("capability provenance superseded during attachment") + } + return nil + } + seal := wc.GetSeal() + if err := check(wc.GetOwnerPrincipalId(), seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetPrincipalEpoch()); err != nil { + return err + } + for _, hop := range wc.GetActorChain() { + epoch, err := s.src.epochIn(tx, hop.GetPrincipalId()) + if err != nil { + return err + } + if epoch != hop.GetPrincipalEpoch() { + return domain.Refuse("actor provenance superseded during attachment") + } + } + return nil +} + +// attachOwned is shared by creation and an authorized transfer. The caller has +// established the active installation and authorization in this write transaction. +func attachOwned(tx Tx, inst domain.Installation, kind, id string) error { + all, err := tx.Installations(inst.Organization) + if err != nil { + return err + } + for _, other := range all { + if other.ID != inst.ID && slices.Contains(other.Owned[kind], id) { + return domain.Refuse("%s %q is owned by installation %s; a resource has one owner in an organisation", kind, id, other.ID) + } + } + if !slices.Contains(inst.Owned[kind], id) { + if len(inst.Owned[kind]) >= MaxOwnedPerKind { + return domain.Refuse("installation %s already owns %d of %s, the most a capability carries", inst.ID, MaxOwnedPerKind, kind) + } + if inst.Owned == nil { + inst.Owned = map[string][]string{} + } + inst.Owned[kind] = append(inst.Owned[kind], id) + slices.Sort(inst.Owned[kind]) + if err := tx.PutInstallation(inst); err != nil { + return err + } + } + return nil +} diff --git a/libraries/authority-core/go/authority/attachment_test.go b/libraries/authority-core/go/authority/attachment_test.go index 38573823..f561509b 100644 --- a/libraries/authority-core/go/authority/attachment_test.go +++ b/libraries/authority-core/go/authority/attachment_test.go @@ -3,6 +3,7 @@ package authority_test import ( "context" "fmt" + "slices" "strings" "sync" "testing" @@ -15,7 +16,7 @@ import ( ) // Present real Core-signed capabilities, including the owner-free scope shape -// issued before solution-principal selection. Attach must enforce its boundary +// with no resource-owner selection. Attach must enforce its boundary // even when a valid capability was minted by another host/version. func attachmentToken(t *testing.T, h *harness, targets []string, scopes ...*basev0.WorkScopeV1) string { t.Helper() @@ -58,11 +59,11 @@ func TestAttachmentRefusalsAndExclusiveBoundedLifecycle(t *testing.T) { refused(t, err, "not a usable id") } for _, tc := range []struct{ name, token, reason string }{ - {"read", attachmentToken(t, h, []string{"a"}, sc(kind, "read")), "holds neither"}, - {"create on id", attachmentToken(t, h, []string{"a"}, owned(kind, "", []string{"create"}, "x")), "holds neither"}, - {"admin on another id", attachmentToken(t, h, []string{"a"}, owned(kind, "", []string{"admin"}, "y")), "holds neither"}, - {"create for another owner", attachmentToken(t, h, []string{"a"}, owned(kind, h.principal(acme, "b"), []string{"create"})), "holds neither"}, - {"selected owner admin", attachmentToken(t, h, []string{"a"}, owned(kind, h.principal(acme, "a"), []string{"admin"}, "x")), "holds neither"}, + {"read", attachmentToken(t, h, []string{"a"}, sc(kind, "read")), "holds no create"}, + {"create on id", attachmentToken(t, h, []string{"a"}, owned(kind, "", []string{"create"}, "x")), "holds no create"}, + {"admin on another id", attachmentToken(t, h, []string{"a"}, owned(kind, "", []string{"admin"}, "y")), "holds no create"}, + {"create for another owner", attachmentToken(t, h, []string{"a"}, owned(kind, h.principal(acme, "b"), []string{"create"})), "holds no create"}, + {"selected owner admin", attachmentToken(t, h, []string{"a"}, owned(kind, h.principal(acme, "a"), []string{"admin"}, "x")), "holds no create"}, {"no installation", attachmentToken(t, h, nil, sc(kind, "create")), "no installation"}, {"two installations", attachmentToken(t, h, []string{"a", "b"}, sc(kind, "create")), "2 installations"}, } { @@ -90,7 +91,7 @@ func TestAttachmentRefusalsAndExclusiveBoundedLifecycle(t *testing.T) { refused(t, err, "owned by") admin := attachmentToken(t, h, []string{"b"}, owned(kind, "", []string{"admin"}, "x")) _, err = attach(h, admin, "x") - refused(t, err, "owned by") + refused(t, err, "AuthorizeTransfer") for n := 1; n < authority.MaxOwnedPerKind; n++ { if _, err := attach(h, token, fmt.Sprintf("id-%d", n)); err != nil { t.Fatal(err) @@ -133,9 +134,8 @@ func TestAttachmentRefusalsAndExclusiveBoundedLifecycle(t *testing.T) { t.Fatal(err) } admin = attachmentToken(t, h, []string{"a"}, owned(kind, "", []string{"admin"}, "existing")) - if _, err := attach(h, admin, "existing"); err != nil { - t.Fatal(err) - } + _, err = attach(h, admin, "existing") + refused(t, err, "AuthorizeTransfer") } func TestConcurrentAttachmentsHaveExactlyOneOwner(t *testing.T) { @@ -190,3 +190,108 @@ func (s *beforeWriteStore) Write(ctx context.Context, f func(authority.Tx) error } return s.Store.Write(ctx, f) } + +func TestPublicIssuanceTransfersOnlyAfterLiveAuthorization(t *testing.T) { + h := newHarness(t) + h.grant("a", "author", "ada") + capability := h.must(h.through("ada", "a", "store")) + for _, row := range effective(capability) { + if row.GetResourceOwnerId() != h.principal(acme, "a") { + t.Fatal("ordinary role lost its resource-owner selector") + } + } + request := authority.TransferRequest{Token: capability.Token, Store: authority.Workload{Module: "store"}, Kind: kind, + ResourceID: "store-x", From: acme, To: h.principal(acme, "a")} + foreign := request + foreign.To = h.principal(globex, "a") + _, err := h.svc.AuthorizeTransfer(h.ctx, foreign) + refused(t, err, "not active at acme") + // A personal grant lost after issue is read live, even without a revision bump. + h.store.PutPerson(domain.Person{ID: "ada", Organization: acme}) + _, err = h.svc.AuthorizeTransfer(h.ctx, request) + refused(t, err, "own organization grants") + h.store.PutPerson(domain.Person{ID: "ada", Organization: acme, Roles: []string{"store-admin"}}) + if err := h.store.Read(h.ctx, func(tx authority.Tx) error { + inst, err := tx.Installation(h.inst(acme, "a").ID) + if len(inst.Owned) != 0 { + t.Fatal("refused transfer changed ownership") + } + return err + }); err != nil { + t.Fatal(err) + } + decision, err := h.svc.AuthorizeTransfer(h.ctx, request) + if err != nil || decision.PrincipalID != request.To { + t.Fatalf("transfer: %+v %v", decision, err) + } + if err := h.store.Read(h.ctx, func(tx authority.Tx) error { + inst, err := tx.Installation(h.inst(acme, "a").ID) + if !slices.Equal(inst.Owned[kind], []string{"store-x"}) { + t.Fatalf("owned: %v", inst.Owned) + } + return err + }); err != nil { + t.Fatal(err) + } +} + +func TestTransferUsesTheAttachmentBoundAndWriteFence(t *testing.T) { + for _, test := range []string{"cap", "revision", "exclusive"} { + t.Run(test, func(t *testing.T) { + h := newHarness(t) + h.grant("a", "author", "ada") + capability := h.must(h.through("ada", "a", "store")) + service := h.svc + reason := "already owns 32" + switch test { + case "cap": + h.grant("a", "author", "alex") + created := h.must(h.through("alex", "a", "store")) + for n := range authority.MaxOwnedPerKind { + if _, err := attach(h, created.Token, fmt.Sprintf("id-%d", n)); err != nil { + t.Fatal(err) + } + } + case "exclusive": + h.grant("b", "author", "alex") + created := h.must(h.through("alex", "b", "store")) + if _, err := attach(h, created.Token, "store-x"); err != nil { + t.Fatal(err) + } + reason = "owned by installation" + case "revision": + wrapped := &beforeWriteStore{Store: h.store, change: func() { + if err := h.store.Write(h.ctx, func(tx authority.Tx) error { return tx.BumpAuthorizationRevision(acme) }); err != nil { + t.Fatal(err) + } + }} + var err error + service, err = authority.New(wrapped, h.cfg) + if err != nil { + t.Fatal(err) + } + reason = "authorization revision superseded" + } + _, err := service.AuthorizeTransfer(h.ctx, authority.TransferRequest{Token: capability.Token, Store: authority.Workload{Module: "store"}, Kind: kind, + ResourceID: "store-x", From: acme, To: h.principal(acme, "a")}) + refused(t, err, reason) + }) + } +} + +func TestPublicCreationIsTenantBoundAndSupportsInstallationAgents(t *testing.T) { + h := newHarness(t) + if _, err := h.svc.GrantRole(h.ctx, h.person("zoe"), authority.RoleRequest{Organization: globex, InstallationID: h.inst(globex, "a").ID, Role: "author", PersonID: "zoe"}); err != nil { + t.Fatal(err) + } + for _, capability := range []authority.Issued{h.must(h.agent("a", "store")), h.must(h.through("zoe", "a", "store"))} { + id, err := attach(h, capability.Token, "same-id") + if err != nil { + t.Fatal(err) + } + want := h.inst(capability.Context.GetTenantId(), "a").ID + if id != want { + t.Fatalf("attached to %s, want tenant installation %s", id, want) + } + } +} diff --git a/libraries/authority-core/go/authority/transfer.go b/libraries/authority-core/go/authority/transfer.go index 2724a733..174c9e7f 100644 --- a/libraries/authority-core/go/authority/transfer.go +++ b/libraries/authority-core/go/authority/transfer.go @@ -25,13 +25,14 @@ type TransferDecision struct { PrincipalID, AuthorizedBy string } -// AuthorizeTransfer decides a transfer, reading everything live. The caller +// AuthorizeTransfer authorizes a transfer and records the installation-owned id +// atomically, reading everything live. The caller // is the kind's one serving module; the capability is addressed to it. func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (TransferDecision, error) { if err := decide.ValidResourceID(r.ResourceID); err != nil { return TransferDecision{}, err } - var store string + var store domain.Module err := s.store.Read(ctx, func(tx Tx) error { m, err := tx.ModuleNamed(r.Store.Module) if err != nil { @@ -44,19 +45,29 @@ func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (Tra if !slices.Contains(servers, m.Name) || len(servers) != 1 { return domain.Refuse("%s does not serve %s alone, so it attests none of its resources", m.Name, r.Kind) } - store = m.Principal + store = m return nil }) if err != nil { return TransferDecision{}, err } - verified, err := s.Verify(ctx, r.Token, store) + verified, err := s.Verify(ctx, r.Token, store.Principal) if err != nil { return TransferDecision{}, err } wc := verified.Context() var decision TransferDecision - err = s.store.Read(ctx, func(tx Tx) error { + err = s.store.Write(ctx, func(tx Tx) error { + current, err := attachmentServer(tx, r.Store, r.Kind) + if err != nil { + return err + } + if current.Principal != store.Principal || current.Generation != store.Generation { + return domain.Refuse("serving module changed during transfer") + } + if err := s.attachmentFresh(tx, wc); err != nil { + return err + } t := decide.Transfer{OwnerKind: wc.GetOwnerPrincipalKind(), From: r.From, To: r.To} if t.OwnerKind != "human" { return decide.AuthorizeTransfer(t) @@ -65,6 +76,9 @@ func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (Tra if err != nil { return domain.Refuse("the owner %s is no longer a member", wc.GetOwnerPrincipalId()) } + if p.Organization != wc.GetTenantId() { + return domain.Refuse("the person no longer belongs to the capability's organization") + } t.Person = p for _, principal := range solutionPrincipals(wc, wc.GetAudience()) { if m, err := tx.Module(principal); err == nil && m.Kind == domain.KindSolution { @@ -89,24 +103,32 @@ func (s *Service) AuthorizeTransfer(ctx context.Context, r TransferRequest) (Tra covered, err := resources.Compile(matchers) t.Administers = err == nil && covered.Matches(r.ResourceID) } - if target, err := tx.InstallationByPrincipal(r.To); err == nil { - solution, err := tx.ModuleNamed(target.Target) - if err != nil { - return notFound(err) - } - grants, err := tx.RoleGrants(target.ID) - if err != nil { - return err - } - role, err := decide.RoleScopes(solution, target, decide.HeldRoles(p, target, grants)) - if err != nil { - return err - } - t.Writes = scope.HoldsAction(role, r.Kind, "write") + target, err := tx.InstallationByPrincipal(r.To) + if err != nil { + return notFound(err) } + if target.Organization != wc.GetTenantId() || target.Status != domain.Active { + return domain.Refuse("target installation %s is not active at %s", target.ID, wc.GetTenantId()) + } + solution, err := tx.ModuleNamed(target.Target) + if err != nil { + return notFound(err) + } + grants, err := tx.RoleGrants(target.ID) + if err != nil { + return err + } + role, err := decide.RoleScopes(solution, target, decide.HeldRoles(p, target, grants)) + if err != nil { + return err + } + t.Writes = scope.HoldsAction(role, r.Kind, "write") if err := decide.AuthorizeTransfer(t); err != nil { return err } + if err := attachOwned(tx, target, r.Kind, r.ResourceID); err != nil { + return err + } decision = TransferDecision{PrincipalID: r.To, AuthorizedBy: p.ID} return nil }) diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 67a21051..29a75683 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -24,7 +24,7 @@ providing different labels does not remove an obligation. | Delivered solution roles at their own audience, binding roots and children; no-role refusals; ceiling enforcement | `SolutionRoles`: a signed wiki viewer role grants Maya documents write, which she lacks independently. Replayed deliveries use deterministic authority-document bytes. | | Rule A: current owner grants intersected with each target binding; canonical `//` identity; named hop refusals | `Chain`, `ChildScopes`, and the individual binding-refusal cases | | Enduring narrowing through two hops | `ChildScopes` and `ParentLimits`: stored delegations retain complete ceilings; parent action/resource limits and entirely dropped kinds remain capped across target changes. Multiple action/resource rows must not become a cross product. | -| Installation-owned role scopes: a role scope limited to what the installation owns, attached from the presenting capability's provenance by `create` on the kind or `admin` on the id; a read-only capability, an administrator of another id, and an id another installation owns are refused, by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens; a detached resource leaves its owner and may be attached again | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, a store module serving the shared kind, and two existing unowned resources; the suite delivers all three modules | +| Installation-owned role scopes: a role scope limited to what the installation owns, created through verified installation provenance with `create` on the whole kind, or recorded after the existing `AuthorizeTransfer` policy authorizes the change of owner; admin-only attachment, missing personal admin, unknown current owners, and an id another installation owns are refused by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens; a detached resource leaves its owner and may be attached again | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, a store module serving the shared kind, and two existing unowned resources; the suite delivers all three modules | | Installation identity, installed caller, explicit organization, pending, active and revoked states | `InstalledSolution`, `InstalledCredential`, `Lifecycle` | | Absent installation versus no entitlement | `FailedPrecondition` with the absent-installation reason versus `PermissionDenied` with the holds-nothing reason | | Inventory access | Admin and owner allowed; ordinary member, anonymous and foreign-organization reads refused | diff --git a/libraries/conformance/go/installation_resources.go b/libraries/conformance/go/installation_resources.go index f52f0dfe..a0ba98e6 100644 --- a/libraries/conformance/go/installation_resources.go +++ b/libraries/conformance/go/installation_resources.go @@ -25,7 +25,7 @@ type InstallationResourcesCase struct { Generation uint64 // ResourceA and ResourceA2 are created through A, ResourceB through B. // ResourceX and ResourceY exist and no installation owns them: Owner, as - // A's installer, administers both, and attaches X only. + // A's installer, has a personal organization grant to administer X only. ResourceA, ResourceA2, ResourceB, ResourceX, ResourceY string } @@ -41,28 +41,27 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur t.Fatal("required conformance fixture missing: signed delivery, two owned installations and five distinct resources") } const binding, self = "store", "self" - create, read, admin := scopeAsk(f.Kind, []string{"create"}, nil), scopeAsk(f.Kind, []string{"read"}, nil), scopeAsk(f.Kind, []string{"admin"}, nil) + create, read, admin, write := scopeAsk(f.Kind, []string{"create"}, nil), scopeAsk(f.Kind, []string{"read"}, nil), scopeAsk(f.Kind, []string{"admin"}, nil), scopeAsk(f.Kind, []string{"write"}, nil) // Store serves Kind; its self binding lets it hop on, and work headless. both := []string{f.Kind + ":create", f.Kind + ":read"} if _, err := c.deliver(ctx, fixture, f.Store, "module", f.Generation, []string{f.Kind}, - map[string]any{"owner_aware": true, "scope_ceilings": []any{scopeAsk(f.Kind, []string{"admin", "create", "read"}, nil)}}, 0, + map[string]any{"owner_aware": true, "scope_ceilings": []any{scopeAsk(f.Kind, []string{"admin", "create", "read", "write"}, nil)}}, 0, solutionhost.AuthorityBinding{ID: self, Audience: f.Store, Operations: map[string][]string{"invoke": both, "headless": both}}); err != nil { t.Fatalf("deliver %s: %v", f.Store, err) } - // Each installer may create, and reads and administers every resource of - // the kind; a viewer reads only what its own installation owns. + // Each installer acts only as its installation's resource-owner principal; a viewer reads only what its own installation owns. for _, s := range []OwnedInstallation{f.A, f.B} { own := scopeAsk(s.Solution+".note", []string{"read"}, nil) doc := map[string]any{ "owner_aware": true, "scope_ceilings": []any{own}, "roles": []any{ - map[string]any{"name": "installer", "scopes": []any{own, create, read, admin}}, + map[string]any{"name": "installer", "scopes": []any{own, create, read, admin, write}}, map[string]any{"name": "viewer", "scopes": []any{own}, "installation_owned_scopes": []any{read}}, }, } if _, err := c.deliver(ctx, fixture, s.Solution, "solution", f.Generation, []string{s.Solution + ".note"}, doc, 0, - solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": append(both, f.Kind+":admin")}}); err != nil { + solutionhost.AuthorityBinding{ID: binding, Audience: f.Store, Operations: map[string][]string{"invoke": append(both, f.Kind+":admin", f.Kind+":write")}}); err != nil { t.Fatalf("deliver %s: %v", s.Solution, err) } } @@ -133,23 +132,26 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur t.Fatal(err) } _, err = attach(str(viewed, "token"), f.StoreCredential, f.ResourceB) - refused(t, err, codes.PermissionDenied, "holds neither") + refused(t, err, codes.PermissionDenied, "holds no create") readB, err := through(t, owner, f.B, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceB})) if err != nil { t.Fatal(err) } _, err = attach(str(readB, "token"), f.StoreCredential, f.ResourceA) - refused(t, err, codes.PermissionDenied, "holds neither") - // A creating capability, or one administering the very id, attaches - // only what no other installation owns. + refused(t, err, codes.PermissionDenied, "holds no create") + // Creation cannot take another installation's id; admin requires transfer. for s, foreign := range map[OwnedInstallation]string{f.A: f.ResourceB, f.B: f.ResourceA} { - for _, ask := range []any{create, scopeAsk(f.Kind, []string{"admin"}, []string{foreign})} { + for index, ask := range []any{create, scopeAsk(f.Kind, []string{"admin"}, []string{foreign})} { capability, err := through(t, owner, s, ask) if err != nil { t.Fatal(err) } _, err = attach(str(capability, "token"), f.StoreCredential, foreign) - refused(t, err, codes.PermissionDenied, "owned by") + reason := "owned by" + if index != 0 { + reason = "AuthorizeTransfer" + } + refused(t, err, codes.PermissionDenied, reason) } } }) @@ -210,25 +212,33 @@ func (c *client) installationResources(t *testing.T, ctx context.Context, fixtur refused(t, err, codes.PermissionDenied, "widens") } }) - t.Run("an administrator of an existing resource attaches it, and only it", func(t *testing.T) { - reads, err := through(t, owner, f.A, scopeAsk(f.Kind, []string{"read"}, []string{f.ResourceX})) + t.Run("existing resources require AuthorizeTransfer and personal grants", func(t *testing.T) { + capability, err := through(t, owner, f.A, admin) if err != nil { t.Fatal(err) } - _, err = attach(str(reads, "token"), f.StoreCredential, f.ResourceX) - refused(t, err, codes.PermissionDenied, "holds neither") - administers, err := through(t, owner, f.A, scopeAsk(f.Kind, []string{"admin"}, []string{f.ResourceX})) - if err != nil { - t.Fatal(err) + _, err = attach(str(capability, "token"), f.StoreCredential, f.ResourceX) + refused(t, err, codes.PermissionDenied, "AuthorizeTransfer") + rows := field(msg(capability, "context"), "authority_scopes").List() + principal := str(rows.Get(0).Message(), "resource_owner_id") + if principal == "" { + t.Fatal("ordinary admin lost its resource-owner selector") } - _, err = attach(str(administers, "token"), f.StoreCredential, f.ResourceY) - refused(t, err, codes.PermissionDenied, "holds neither") - out, err := attach(str(administers, "token"), f.StoreCredential, f.ResourceX) + transfer := func(id, from string) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "AuthorizeTransfer", map[string]any{ + "token": str(capability, "token"), "actor": map[string]any{"credential": f.StoreCredential}, + "resource_kind": f.Kind, "resource_id": id, "from_owner_id": from, "to_principal_id": principal}) + } + _, err = transfer(f.ResourceY, fixture.Organization) + refused(t, err, codes.PermissionDenied, "own organization grants") + _, err = transfer(f.ResourceX, "unknown-owner") + refused(t, err, codes.PermissionDenied, "not a person or the organization") + out, err := transfer(f.ResourceX, fixture.Organization) if err != nil { t.Fatal(err) } - if got := str(out, "installation_id"); got != f.A.Installation { - t.Fatalf("%s attached to %s, want %s", f.ResourceX, got, f.A.Installation) + if got := str(out, "principal_id"); got != principal { + t.Fatalf("transfer owner %s, want %s", got, principal) } viewed, err := through(t, viewer, f.A) if err != nil { diff --git a/modules/authority/services/local/code/pkg/business/issuer/attach_test.go b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go index 3bef2a0f..102140fb 100644 --- a/modules/authority/services/local/code/pkg/business/issuer/attach_test.go +++ b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go @@ -103,8 +103,8 @@ func TestASolutionAttachesItsOwnKindThroughAPersonsCapability(t *testing.T) { } } -// An installation acting for itself attaches what it administers into itself. -func TestAnInstallationAgentAttachesIntoItsInstallation(t *testing.T) { +// An installation acting for itself cannot transfer an existing resource. +func TestAnInstallationAgentCannotAttachAnExistingResource(t *testing.T) { k := testkit.New(t) id := ownKindSolution(t, k) agent, err := k.Issuer.Issue(context.Background(), &authorityv1.IssueRequest{ @@ -116,20 +116,12 @@ func TestAnInstallationAgentAttachesIntoItsInstallation(t *testing.T) { if agent.GetContext().GetOwnerPrincipalKind() != "agent" { t.Fatalf("owner kind %q", agent.GetContext().GetOwnerPrincipalKind()) } - got, err := k.Issuer.AttachResource(t.Context(), agent.GetToken(), k.Credential(t, "documents"), "documents.collection", "c-agent") - if err != nil || got != id { - t.Fatalf("attached to %q (%v), want %s", got, err, id) - } - m, err := k.State.ModuleByPrincipal("proposals") - if err != nil { - t.Fatal(err) + _, err = k.Issuer.AttachResource(t.Context(), agent.GetToken(), k.Credential(t, "documents"), "documents.collection", "c-agent") + if !errors.Is(err, issuer.ErrRefused) || !strings.Contains(err.Error(), "AuthorizeTransfer") { + t.Fatalf("admin-only attachment must name the transfer seam: %v", err) } inst, _ := k.State.Installation(id) - held, err := decide.RoleScopes(m, inst, []string{"viewer"}) - if err != nil { - t.Fatal(err) - } - if ids := ownedRead(held, "documents.collection"); !slices.Equal(ids, []string{"c-agent"}) { - t.Fatalf("the viewer reads %v, want [c-agent]", ids) + if len(inst.Owned) != 0 { + t.Fatal("refused attachment changed ownership") } } diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go index 0b1268b7..c2e63df5 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go @@ -66,11 +66,11 @@ type AuthorityServiceClient interface { // serving module (a kind two composed modules declare attaches nothing), // proving itself with its workload credential, and presents a capability // addressed to it, at the organisation's current authorization revision, - // holding either the action "create" on the whole kind (the call that created - // the resource) or the action "admin" on that resource id, by name or on the - // whole kind (an administrator attaching an existing resource, as an - // installer configuring a solution's collection). A capability that only - // reads attaches nothing. A resource has one owner in an organisation: an id + // holding "create" on the whole kind for the newly created resource. An + // owner-selected create must select this installation's principal. Admin or + // read alone attaches nothing: an existing resource changes owner through + // AuthorizeTransfer, never through an unrestricted administrative capability. + // A resource has one owner in an organisation: an id // another installation owns is refused. A store whose attach is refused fails // or rolls back the create it made, and detaches what it deletes // (DetachResource). The owning installation is read from the capability's @@ -100,8 +100,14 @@ type AuthorityServiceClient interface { // owner), the person holds admin on that resource from their own // organization grants, and the person's current roles on the principal hold // write on the kind, all read live. An installation acting for itself never - // transfers. The store then records the principal as owner; the original - // creator stays the author. + // transfers. After authorization succeeds, the authority records the id in + // the target installation's owned set in the same write transaction, subject + // to the same exclusivity, id and 32-id bounds as AttachResource. Revision, + // provenance, serving module and active tenant installation are checked in + // that transaction. A refusal records nothing and names its reason. The + // store then records the principal as owner; the original creator stays the + // author. The store must complete that change or compensate with DetachResource + // before exposing the resource. No separate admin attachment path exists. AuthorizeTransfer(ctx context.Context, in *AuthorizeTransferRequest, opts ...grpc.CallOption) (*AuthorizeTransferResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. @@ -251,11 +257,11 @@ type AuthorityServiceServer interface { // serving module (a kind two composed modules declare attaches nothing), // proving itself with its workload credential, and presents a capability // addressed to it, at the organisation's current authorization revision, - // holding either the action "create" on the whole kind (the call that created - // the resource) or the action "admin" on that resource id, by name or on the - // whole kind (an administrator attaching an existing resource, as an - // installer configuring a solution's collection). A capability that only - // reads attaches nothing. A resource has one owner in an organisation: an id + // holding "create" on the whole kind for the newly created resource. An + // owner-selected create must select this installation's principal. Admin or + // read alone attaches nothing: an existing resource changes owner through + // AuthorizeTransfer, never through an unrestricted administrative capability. + // A resource has one owner in an organisation: an id // another installation owns is refused. A store whose attach is refused fails // or rolls back the create it made, and detaches what it deletes // (DetachResource). The owning installation is read from the capability's @@ -285,8 +291,14 @@ type AuthorityServiceServer interface { // owner), the person holds admin on that resource from their own // organization grants, and the person's current roles on the principal hold // write on the kind, all read live. An installation acting for itself never - // transfers. The store then records the principal as owner; the original - // creator stays the author. + // transfers. After authorization succeeds, the authority records the id in + // the target installation's owned set in the same write transaction, subject + // to the same exclusivity, id and 32-id bounds as AttachResource. Revision, + // provenance, serving module and active tenant installation are checked in + // that transaction. A refusal records nothing and names its reason. The + // store then records the principal as owner; the original creator stays the + // author. The store must complete that change or compensate with DetachResource + // before exposing the resource. No separate admin attachment path exists. AuthorizeTransfer(context.Context, *AuthorizeTransferRequest) (*AuthorizeTransferResponse, error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go index 545d2699..c340058b 100644 --- a/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go +++ b/modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go @@ -92,11 +92,11 @@ type AuthorityServiceClient interface { // serving module (a kind two composed modules declare attaches nothing), // proving itself with its workload credential, and presents a capability // addressed to it, at the organisation's current authorization revision, - // holding either the action "create" on the whole kind (the call that created - // the resource) or the action "admin" on that resource id, by name or on the - // whole kind (an administrator attaching an existing resource, as an - // installer configuring a solution's collection). A capability that only - // reads attaches nothing. A resource has one owner in an organisation: an id + // holding "create" on the whole kind for the newly created resource. An + // owner-selected create must select this installation's principal. Admin or + // read alone attaches nothing: an existing resource changes owner through + // AuthorizeTransfer, never through an unrestricted administrative capability. + // A resource has one owner in an organisation: an id // another installation owns is refused. A store whose attach is refused fails // or rolls back the create it made, and detaches what it deletes // (DetachResource). The owning installation is read from the capability's @@ -126,8 +126,14 @@ type AuthorityServiceClient interface { // owner), the person holds admin on that resource from their own // organization grants, and the person's current roles on the principal hold // write on the kind, all read live. An installation acting for itself never - // transfers. The store then records the principal as owner; the original - // creator stays the author. + // transfers. After authorization succeeds, the authority records the id in + // the target installation's owned set in the same write transaction, subject + // to the same exclusivity, id and 32-id bounds as AttachResource. Revision, + // provenance, serving module and active tenant installation are checked in + // that transaction. A refusal records nothing and names its reason. The + // store then records the principal as owner; the original creator stays the + // author. The store must complete that change or compensate with DetachResource + // before exposing the resource. No separate admin attachment path exists. AuthorizeTransfer(context.Context, *connect.Request[v1.AuthorizeTransferRequest]) (*connect.Response[v1.AuthorizeTransferResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. @@ -303,11 +309,11 @@ type AuthorityServiceHandler interface { // serving module (a kind two composed modules declare attaches nothing), // proving itself with its workload credential, and presents a capability // addressed to it, at the organisation's current authorization revision, - // holding either the action "create" on the whole kind (the call that created - // the resource) or the action "admin" on that resource id, by name or on the - // whole kind (an administrator attaching an existing resource, as an - // installer configuring a solution's collection). A capability that only - // reads attaches nothing. A resource has one owner in an organisation: an id + // holding "create" on the whole kind for the newly created resource. An + // owner-selected create must select this installation's principal. Admin or + // read alone attaches nothing: an existing resource changes owner through + // AuthorizeTransfer, never through an unrestricted administrative capability. + // A resource has one owner in an organisation: an id // another installation owns is refused. A store whose attach is refused fails // or rolls back the create it made, and detaches what it deletes // (DetachResource). The owning installation is read from the capability's @@ -337,8 +343,14 @@ type AuthorityServiceHandler interface { // owner), the person holds admin on that resource from their own // organization grants, and the person's current roles on the principal hold // write on the kind, all read live. An installation acting for itself never - // transfers. The store then records the principal as owner; the original - // creator stays the author. + // transfers. After authorization succeeds, the authority records the id in + // the target installation's owned set in the same write transaction, subject + // to the same exclusivity, id and 32-id bounds as AttachResource. Revision, + // provenance, serving module and active tenant installation are checked in + // that transaction. A refusal records nothing and names its reason. The + // store then records the principal as owner; the original creator stays the + // author. The store must complete that change or compensate with DetachResource + // before exposing the resource. No separate admin attachment path exists. AuthorizeTransfer(context.Context, *connect.Request[v1.AuthorizeTransferRequest]) (*connect.Response[v1.AuthorizeTransferResponse], error) // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go index 46a07914..808b88c3 100644 --- a/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go +++ b/modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go @@ -528,8 +528,8 @@ type RoleV1 struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Scopes []*v1.WorkScopeV1 `protobuf:"bytes,2,rep,name=scopes,proto3" json:"scopes,omitempty"` // Scopes the role grants only on the resources the installation owns: those - // created through it or attached by its administrator - // (AuthorityService.AttachResource). Each + // created through it (AuthorityService.AttachResource) or transferred after + // AuthorityService.AuthorizeTransfer authorizes the change of owner. Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go index bd5f9a49..4f4def99 100644 --- a/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go +++ b/modules/authority/services/local/code/plugins/registry_test/local_authority_test.go @@ -125,6 +125,24 @@ people: t.Fatal(err) } } + // The transfer seam reads this person's own resource grant, never the + // solution's owner-selected admin scope. Y deliberately has no grant. + if err := k.State.LoadFixture(context.Background(), []byte(`roles: + - organization: acme + name: collection-transfer + scopes: + - kind: conformance.collection + actions: [admin] + resources: [{id: collection-x}] +people: + - id: alex + fixture_token: fixture-alex + organization: acme + roles: [admin, collection-transfer] + teams: [finance, engineering] +`)); err != nil { + t.Fatal(err) + } // Two installed solutions for installation-owned role scopes: Alex installed // both, and Maya's finance team holds A's viewer role and nothing of B's. owned := map[string]string{} diff --git a/modules/authority/services/local/proto/host/authority/v1/authority.proto b/modules/authority/services/local/proto/host/authority/v1/authority.proto index 3d3c0f81..7b716dcc 100644 --- a/modules/authority/services/local/proto/host/authority/v1/authority.proto +++ b/modules/authority/services/local/proto/host/authority/v1/authority.proto @@ -39,11 +39,11 @@ service AuthorityService { // serving module (a kind two composed modules declare attaches nothing), // proving itself with its workload credential, and presents a capability // addressed to it, at the organisation's current authorization revision, - // holding either the action "create" on the whole kind (the call that created - // the resource) or the action "admin" on that resource id, by name or on the - // whole kind (an administrator attaching an existing resource, as an - // installer configuring a solution's collection). A capability that only - // reads attaches nothing. A resource has one owner in an organisation: an id + // holding "create" on the whole kind for the newly created resource. An + // owner-selected create must select this installation's principal. Admin or + // read alone attaches nothing: an existing resource changes owner through + // AuthorizeTransfer, never through an unrestricted administrative capability. + // A resource has one owner in an organisation: an id // another installation owns is refused. A store whose attach is refused fails // or rolls back the create it made, and detaches what it deletes // (DetachResource). The owning installation is read from the capability's @@ -73,8 +73,14 @@ service AuthorityService { // owner), the person holds admin on that resource from their own // organization grants, and the person's current roles on the principal hold // write on the kind, all read live. An installation acting for itself never - // transfers. The store then records the principal as owner; the original - // creator stays the author. + // transfers. After authorization succeeds, the authority records the id in + // the target installation's owned set in the same write transaction, subject + // to the same exclusivity, id and 32-id bounds as AttachResource. Revision, + // provenance, serving module and active tenant installation are checked in + // that transaction. A refusal records nothing and names its reason. The + // store then records the principal as owner; the original creator stays the + // author. The store must complete that change or compensate with DetachResource + // before exposing the resource. No separate admin attachment path exists. rpc AuthorizeTransfer(AuthorizeTransferRequest) returns (AuthorizeTransferResponse); // Delegate records a signed-in person's grant to a module (the caller's // bearer is the person); Issue with a delegation acts on it. diff --git a/modules/authority/services/local/proto/host/composition/v1/composition.proto b/modules/authority/services/local/proto/host/composition/v1/composition.proto index bad946de..a834fd4c 100644 --- a/modules/authority/services/local/proto/host/composition/v1/composition.proto +++ b/modules/authority/services/local/proto/host/composition/v1/composition.proto @@ -127,8 +127,8 @@ message RoleV1 { string name = 1; repeated host.authority.v1.WorkScopeV1 scopes = 2; // Scopes the role grants only on the resources the installation owns: those - // created through it or attached by its administrator - // (AuthorityService.AttachResource). Each + // created through it (AuthorityService.AttachResource) or transferred after + // AuthorityService.AuthorizeTransfer authorizes the change of owner. Each // names a kind and actions and no resource ids, since ids are per // installation, and is held to the same ceiling as scopes. The authority // resolves each, for the installation the role is held on, into the same diff --git a/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index beb16564..cd992d5e 100644 --- a/tools/noproxygate/generated.json +++ b/tools/noproxygate/generated.json @@ -4,9 +4,9 @@ "libraries/authority-client/go/gen/host/artifact/v1/artifact.pb.go": "8f032d82c2a6921e47ec08cde4c797fa75ec05e6c7cccb5b5a1be744805e8f4e", "libraries/authority-client/go/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "e9e35ce1a1b3e5d627b33b44653ae1d7913d684dfe12d512b2d8fd6526dd294a", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "8ce02ec6e526d80999b65649f83c3a23e446cf3ef9104ace51772d234c17e2f0", + "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "bfabf589bea73aaa909d7889ccb05dfbd7f5001482a3c68aafeec70383a66577", "libraries/authority-client/go/gen/host/authority/v1/work_context.pb.go": "c53b6b47355ee9a502441173931b3b809e510054168aa0cc2984b5152e771bf1", - "libraries/authority-client/go/gen/host/composition/v1/composition.pb.go": "e4f69c1d50109b7150e73edf8e4c3f7e68feddfc88c08b626bd4a9f93594baef", + "libraries/authority-client/go/gen/host/composition/v1/composition.pb.go": "8fc4657b7b207b2fd80db74b238b10e5b0afd65b25851d56817cb0b960b1c750", "libraries/authority-client/go/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", "libraries/authority-client/go/gen/host/installation/v1/installation.pb.go": "c5cc4de93487e6bd1aa2a92457102e86abecd97b51fa00c427a297180f60b0b9", "libraries/authority-client/go/gen/host/installation/v1/installation_grpc.pb.go": "b698a9175b07ec08ef22d80386cb4a9f4b1f8fd47d9d144a246cbe528e02394a", @@ -41,10 +41,10 @@ "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifactv1connect/artifact.connect.go": "8d5ab439f1293c76d3b5d2fe0e2a4845694a08b007f13c62f19c2de6c6e2b20f", "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.go": "8394375d99a2fcc535ff76821f0793897d17356d089c8463d28e5043ce70cf73", "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority.pb.gw.go": "37a1508e28e8bdb5310471401c9f78a5b14faa646e826bc5592ef64684fb7ea2", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "8ce02ec6e526d80999b65649f83c3a23e446cf3ef9104ace51772d234c17e2f0", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "2ecfcb1ae2213e2728e02e867d80c5e76a78b0d5db2397f823ecbde0f0d6608e", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authority_grpc.pb.go": "bfabf589bea73aaa909d7889ccb05dfbd7f5001482a3c68aafeec70383a66577", + "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "988913b6a4466f9143731dedc62782c468769d156c7cc5aa9fa6062efaee0f87", "modules/authority/services/local/code/pkg/gen/host/authority/v1/work_context.pb.go": "30da6150c9d3567ba0552590dd4ae3736f313097d4f4df8fcbaf242fca8af029", - "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go": "493c9bad71b3de59adafadbcbd8a15668c976d9efbfb94c1a5c1b91184b4707a", + "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition.pb.go": "c7a1220dc4bfeb3419485a4b41b5a8a5f8a9a35654180402b46010345f5094e4", "modules/authority/services/local/code/pkg/gen/host/composition/v1/composition_grpc.pb.go": "acd95033d684741c23c74eb9f05021ebe487b9ab9e9cd81d549d44ab6c6d6162", "modules/authority/services/local/code/pkg/gen/host/composition/v1/compositionv1connect/composition.connect.go": "d1c06a0ee8ffad586e36271b773e52a43737e49c4473207e1fa03b922f3fe88f", "modules/authority/services/local/code/pkg/gen/host/installation/v1/installation.pb.go": "7aee011a95a4e3a91dfdcfd0c5897f86bab99c97b36121a770bf52d0e177c9a6",