diff --git a/README.md b/README.md index c8200e6a..87d3e118 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,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.1`, `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 / @@ -276,7 +276,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.1`, `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 @@ -956,3 +956,40 @@ 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.1` 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 owns. The kind's one serving +module attaches a resource from a capability holding `create` on the whole kind +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 +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 +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 +persist `domain.Module.OwnedRoles` and `domain.Installation.Owned` through the +shared Store contract. Ordinary roles retain main's resource-owner selection; +installation-owned rows contain only attached ids, and an empty owned set emits +no grant. The local host is an adapter to that core. Accounts adoption and +its durable adapter are not qualified by a local authority test. + +The 0.3.1 authority version is an additive pre-stable revision selected for +this refresh so existing `>=0.2.0 <0.4.0` consumers admit it. It retains 0.3.0's +`AuthorizeTransfer` and adds attachment operations. Composition remains 0.2.0. diff --git a/interfaces/authority/interface.codefly.yaml b/interfaces/authority/interface.codefly.yaml index abae8de9..3e416475 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.3.0 +version: 0.3.1 type: connect protobuf: package: host.authority.v1 services: - name: AuthorityService - methods: [Issue, Check, Keys, ListResources, AuthorizeTransfer, Delegate, RevokeDelegation, GetDelegation] + methods: [Issue, Check, Keys, ListResources, AuthorizeTransfer, AttachResource, DetachResource, Delegate, RevokeDelegation, GetDelegation] diff --git a/libraries/authority-client/contract/catalog.codefly.json b/libraries/authority-client/contract/catalog.codefly.json index 9ff1b5cf..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:f70c838a51dc2bfe1d95bfec4a1ab0f67d7bfd48ce60ccce58de30d342dce6c0", + "digest": "sha256:c991396ffbfdc2ec53c75e6dd9ea244a15951ad78bb0a99cf00e24a788cb9f74", "services": [ { "name": "LocalService", diff --git a/libraries/authority-client/contract/contract.binpb b/libraries/authority-client/contract/contract.binpb index fd1a4b66..c5f42926 100644 Binary files a/libraries/authority-client/contract/contract.binpb and b/libraries/authority-client/contract/contract.binpb differ diff --git a/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go b/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go index cd75c76e..c0a5c616 100644 --- a/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go +++ b/libraries/authority-client/go/gen/host/authority/v1/authority.pb.go @@ -1217,6 +1217,244 @@ 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 "" +} + +// 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 "" +} + // AuthorizeTransferRequest names one existing resource and where it moves. type AuthorizeTransferRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1239,7 +1477,7 @@ type AuthorizeTransferRequest struct { func (x *AuthorizeTransferRequest) Reset() { *x = AuthorizeTransferRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1251,7 +1489,7 @@ func (x *AuthorizeTransferRequest) String() string { func (*AuthorizeTransferRequest) ProtoMessage() {} func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1264,7 +1502,7 @@ func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferRequest.ProtoReflect.Descriptor instead. func (*AuthorizeTransferRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *AuthorizeTransferRequest) GetToken() string { @@ -1322,7 +1560,7 @@ type AuthorizeTransferResponse struct { func (x *AuthorizeTransferResponse) Reset() { *x = AuthorizeTransferResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1334,7 +1572,7 @@ func (x *AuthorizeTransferResponse) String() string { func (*AuthorizeTransferResponse) ProtoMessage() {} func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1347,7 +1585,7 @@ func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferResponse.ProtoReflect.Descriptor instead. func (*AuthorizeTransferResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} } func (x *AuthorizeTransferResponse) GetPrincipalId() string { @@ -1384,7 +1622,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[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1396,7 +1634,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1409,7 +1647,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{24} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1578,7 +1816,23 @@ 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\"\xff\x01\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\"\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\"\xff\x01\n" + "\x18AuthorizeTransferRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12;\n" + "\x05actor\x18\x02 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12#\n" + @@ -1596,12 +1850,14 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apattern\x12*\n" + "\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\a\n" + - "\x05match2\x9c\x06\n" + + "\x05match2\xea\a\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\x12n\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\x12e\n" + + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n" + "\x11AuthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\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" + @@ -1620,7 +1876,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, 25) var file_host_authority_v1_authority_proto_goTypes = []any{ (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef @@ -1640,49 +1896,59 @@ 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 - (*AuthorizeTransferRequest)(nil), // 18: host.authority.v1.AuthorizeTransferRequest - (*AuthorizeTransferResponse)(nil), // 19: host.authority.v1.AuthorizeTransferResponse - (*ResourceGrant)(nil), // 20: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 21: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 22: host.authority.v1.WorkContextV1 + (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse + (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse + (*AuthorizeTransferRequest)(nil), // 22: host.authority.v1.AuthorizeTransferRequest + (*AuthorizeTransferResponse)(nil), // 23: host.authority.v1.AuthorizeTransferResponse + (*ResourceGrant)(nil), // 24: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 25: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 26: 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 + 25, // 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 + 25, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 25, // 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 + 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 26, // 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 - 8, // 14: host.authority.v1.AuthorizeTransferRequest.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.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 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.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 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 + 24, // 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 + 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 16: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 17: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 18: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 19: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 20: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 21: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 20, // 22: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 22, // 23: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 24: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 25: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 26: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 27: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 28: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 29: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 30: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 31: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 21, // 32: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 23, // 33: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 34: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 35: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 36: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 27, // [27:37] is the sub-list for method output_type + 17, // [17:27] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1698,7 +1964,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[24].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1710,7 +1976,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: 25, 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 e548980e..c2e63df5 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,8 @@ 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_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" @@ -58,6 +60,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 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 "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 + // 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. 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) // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -68,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. @@ -131,6 +169,26 @@ 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) 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) AuthorizeTransfer(ctx context.Context, in *AuthorizeTransferRequest, opts ...grpc.CallOption) (*AuthorizeTransferResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(AuthorizeTransferResponse) @@ -193,6 +251,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 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 "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 + // 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. 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) // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -203,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. @@ -238,6 +332,12 @@ 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) DetachResource(context.Context, *DetachResourceRequest) (*DetachResourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DetachResource not implemented") +} func (UnimplementedAuthorityServiceServer) AuthorizeTransfer(context.Context, *AuthorizeTransferRequest) (*AuthorizeTransferResponse, error) { return nil, status.Error(codes.Unimplemented, "method AuthorizeTransfer not implemented") } @@ -343,6 +443,42 @@ 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_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_AuthorizeTransfer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(AuthorizeTransferRequest) if err := dec(in); err != nil { @@ -438,6 +574,14 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, }, + { + MethodName: "AttachResource", + Handler: _AuthorityService_AttachResource_Handler, + }, + { + MethodName: "DetachResource", + Handler: _AuthorityService_DetachResource_Handler, + }, { MethodName: "AuthorizeTransfer", Handler: _AuthorityService_AuthorizeTransfer_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 1ee25c52..e2001f11 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 @@ -524,11 +524,20 @@ 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 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 + // 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() { @@ -575,6 +584,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"` @@ -816,10 +832,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" + @@ -875,16 +892,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 d5cef845..0f5e1d45 100644 --- a/libraries/authority-client/library.codefly.yaml +++ b/libraries/authority-client/library.codefly.yaml @@ -20,10 +20,10 @@ sources: module: authority service: local endpoint: grpc - contract-digest: sha256:f70c838a51dc2bfe1d95bfec4a1ab0f67d7bfd48ce60ccce58de30d342dce6c0 + contract-digest: sha256:c991396ffbfdc2ec53c75e6dd9ea244a15951ad78bb0a99cf00e24a788cb9f74 services: - LocalService generated-by: codefly: development - companion: ghcr.io/codefly-dev/proto:0.0.17 + companion: ghcr.io/codefly-dev/proto:0.0.18 facade: false diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py index ee576049..4bf50adb 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2.py @@ -25,7 +25,7 @@ from authority_client._gen.host.authority.v1 import work_context_pb2 as host_dot_authority_dot_v1_dot_work__context__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"X\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xdc\x05\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\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') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!host/authority/v1/authority.proto\x12\x11host.authority.v1\x1a$host/authority/v1/work_context.proto\"\xf3\x04\n\x0cIssueRequest\x12:\n\x06person\x18\x01 \x01(\x0b\x32 .host.authority.v1.PersonSessionH\x00R\x06person\x12\x43\n\x08workload\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialH\x00R\x08workload\x12H\n\x0cinstallation\x18\x03 \x01(\x0b\x32\".host.authority.v1.InstallationRefH\x00R\x0cinstallation\x12#\n\x0cparent_token\x18\x04 \x01(\tH\x00R\x0bparentToken\x12\x42\n\ndelegation\x18\x0b \x01(\x0b\x32 .host.authority.v1.DelegationRefH\x00R\ndelegation\x12\x1a\n\x08\x61udience\x18\x05 \x01(\tR\x08\x61udience\x12\x36\n\x06scopes\x18\x06 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x1f\n\x0bttl_seconds\x18\x07 \x01(\x03R\nttlSeconds\x12\x30\n\x14operation_binding_id\x18\x08 \x01(\tR\x12operationBindingId\x12\'\n\x0forganization_id\x18\t \x01(\tR\x0eorganizationId\x12;\n\x05\x61\x63tor\x18\n \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\x1c\n\toperation\x18\x0c \x01(\tR\toperationB\x04\n\x02\x61s\"4\n\rDelegationRef\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"{\n\x0f\x44\x65legateRequest\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x03 \x01(\tR\x07purpose\"\xf6\x01\n\nDelegation\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12\x18\n\x07grantor\x18\x03 \x01(\tR\x07grantor\x12\x16\n\x06module\x18\x04 \x01(\tR\x06module\x12\x36\n\x06scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12\x18\n\x07purpose\x18\x06 \x01(\tR\x07purpose\x12\x16\n\x06\x61\x63tive\x18\x07 \x01(\x08R\x06\x61\x63tive\"x\n\x14GetDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\">\n\x17RevokeDelegationRequest\x12#\n\rdelegation_id\x18\x01 \x01(\tR\x0c\x64\x65legationId\"Y\n\x18RevokeDelegationResponse\x12=\n\ndelegation\x18\x01 \x01(\x0b\x32\x1d.host.authority.v1.DelegationR\ndelegation\"!\n\rPersonSessionJ\x04\x08\x01\x10\x02R\nsession_id\"4\n\x12WorkloadCredential\x12\x1e\n\ncredential\x18\x01 \x01(\tR\ncredential\":\n\x0fInstallationRef\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x89\x01\n\rIssueResponse\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12&\n\x0f\x65xpires_at_unix\x18\x02 \x01(\x03R\rexpiresAtUnix\x12:\n\x07\x63ontext\x18\x03 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\"t\n\x0c\x43heckRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x16\n\x06method\x18\x03 \x01(\tR\x06method\x12\x1a\n\x08\x61\x64visory\x18\x04 \x01(\x08R\x08\x61\x64visory\"\x9c\x01\n\rCheckResponse\x12:\n\x07\x63ontext\x18\x01 \x01(\x0b\x32 .host.authority.v1.WorkContextV1R\x07\x63ontext\x12\x35\n\x16\x61uthorization_revision\x18\x02 \x01(\x04R\x15\x61uthorizationRevision\x12\x18\n\x07refusal\x18\x03 \x01(\tR\x07refusal\"\r\n\x0bKeysRequest\"X\n\x0cKeysResponse\x12\x16\n\x06issuer\x18\x01 \x01(\tR\x06issuer\x12\x30\n\x04keys\x18\x02 \x03(\x0b\x32\x1c.host.authority.v1.PublicKeyR\x04keys\"_\n\tPublicKey\x12\x15\n\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n\talgorithm\x18\x02 \x01(\tR\talgorithm\x12\x1d\n\npublic_key\x18\x03 \x01(\x0cR\tpublicKey\"\x85\x01\n\x14ListResourcesRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x16\n\x06\x61\x63tion\x18\x04 \x01(\tR\x06\x61\x63tion\"Q\n\x15ListResourcesResponse\x12\x38\n\x06grants\x18\x01 \x03(\x0b\x32 .host.authority.v1.ResourceGrantR\x06grants\"\xb0\x01\n\x15\x41ttachResourceRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x41ttachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xc3\x01\n\x15\x44\x65tachResourceRequest\x12;\n\x05\x61\x63tor\x18\x01 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12\'\n\x0forganization_id\x18\x02 \x01(\tR\x0eorganizationId\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\"A\n\x16\x44\x65tachResourceResponse\x12\'\n\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xff\x01\n\x18\x41uthorizeTransferRequest\x12\x14\n\x05token\x18\x01 \x01(\tR\x05token\x12;\n\x05\x61\x63tor\x18\x02 \x01(\x0b\x32%.host.authority.v1.WorkloadCredentialR\x05\x61\x63tor\x12#\n\rresource_kind\x18\x03 \x01(\tR\x0cresourceKind\x12\x1f\n\x0bresource_id\x18\x04 \x01(\tR\nresourceId\x12\"\n\rfrom_owner_id\x18\x05 \x01(\tR\x0b\x66romOwnerId\x12&\n\x0fto_principal_id\x18\x06 \x01(\tR\rtoPrincipalId\"c\n\x19\x41uthorizeTransferResponse\x12!\n\x0cprincipal_id\x18\x01 \x01(\tR\x0bprincipalId\x12#\n\rauthorized_by\x18\x02 \x01(\tR\x0c\x61uthorizedBy\"\xa0\x01\n\rResourceGrant\x12\x12\n\x03\x61ll\x18\x01 \x01(\x08H\x00R\x03\x61ll\x12\x10\n\x02id\x18\x02 \x01(\tH\x00R\x02id\x12\x18\n\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n\x07pattern\x18\x04 \x01(\tH\x00R\x07pattern\x12*\n\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\x07\n\x05match2\xaa\x07\n\x10\x41uthorityService\x12J\n\x05Issue\x12\x1f.host.authority.v1.IssueRequest\x1a .host.authority.v1.IssueResponse\x12J\n\x05\x43heck\x12\x1f.host.authority.v1.CheckRequest\x1a .host.authority.v1.CheckResponse\x12G\n\x04Keys\x12\x1e.host.authority.v1.KeysRequest\x1a\x1f.host.authority.v1.KeysResponse\x12\x62\n\rListResources\x12\'.host.authority.v1.ListResourcesRequest\x1a(.host.authority.v1.ListResourcesResponse\x12\x65\n\x0e\x41ttachResource\x12(.host.authority.v1.AttachResourceRequest\x1a).host.authority.v1.AttachResourceResponse\x12\x65\n\x0e\x44\x65tachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n\x11\x41uthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\x12M\n\x08\x44\x65legate\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') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -69,12 +69,20 @@ _globals['_LISTRESOURCESREQUEST']._serialized_end=2331 _globals['_LISTRESOURCESRESPONSE']._serialized_start=2333 _globals['_LISTRESOURCESRESPONSE']._serialized_end=2414 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=2417 - _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=2672 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=2674 - _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=2773 - _globals['_RESOURCEGRANT']._serialized_start=2776 - _globals['_RESOURCEGRANT']._serialized_end=2936 - _globals['_AUTHORITYSERVICE']._serialized_start=2939 - _globals['_AUTHORITYSERVICE']._serialized_end=3671 + _globals['_ATTACHRESOURCEREQUEST']._serialized_start=2417 + _globals['_ATTACHRESOURCEREQUEST']._serialized_end=2593 + _globals['_ATTACHRESOURCERESPONSE']._serialized_start=2595 + _globals['_ATTACHRESOURCERESPONSE']._serialized_end=2660 + _globals['_DETACHRESOURCEREQUEST']._serialized_start=2663 + _globals['_DETACHRESOURCEREQUEST']._serialized_end=2858 + _globals['_DETACHRESOURCERESPONSE']._serialized_start=2860 + _globals['_DETACHRESOURCERESPONSE']._serialized_end=2925 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_start=2928 + _globals['_AUTHORIZETRANSFERREQUEST']._serialized_end=3183 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_start=3185 + _globals['_AUTHORIZETRANSFERRESPONSE']._serialized_end=3284 + _globals['_RESOURCEGRANT']._serialized_start=3287 + _globals['_RESOURCEGRANT']._serialized_end=3447 + _globals['_AUTHORITYSERVICE']._serialized_start=3450 + _globals['_AUTHORITYSERVICE']._serialized_end=4388 # @@protoc_insertion_point(module_scope) diff --git a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py index 349fb362..eed48328 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py +++ b/libraries/authority-client/python/authority_client/_gen/host/authority/v1/authority_pb2_grpc.py @@ -55,6 +55,16 @@ def __init__(self, channel): request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesRequest.SerializeToString, response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesResponse.FromString, _registered_method=True) + self.AttachResource = channel.unary_unary( + '/host.authority.v1.AuthorityService/AttachResource', + request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.AttachResourceRequest.SerializeToString, + response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.AttachResourceResponse.FromString, + _registered_method=True) + self.DetachResource = channel.unary_unary( + '/host.authority.v1.AuthorityService/DetachResource', + request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.DetachResourceRequest.SerializeToString, + response_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.DetachResourceResponse.FromString, + _registered_method=True) self.AuthorizeTransfer = channel.unary_unary( '/host.authority.v1.AuthorityService/AuthorizeTransfer', request_serializer=host_dot_authority_dot_v1_dot_authority__pb2.AuthorizeTransferRequest.SerializeToString, @@ -117,6 +127,46 @@ def ListResources(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def AttachResource(self, request, context): + """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 "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 + 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. 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. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def DetachResource(self, request, context): + """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. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def AuthorizeTransfer(self, request, context): """AuthorizeTransfer decides whether an existing resource may join an installed solution's principal. The caller is the kind's one serving @@ -128,8 +178,14 @@ def AuthorizeTransfer(self, request, context): 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. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') @@ -183,6 +239,16 @@ def add_AuthorityServiceServicer_to_server(servicer, server): request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesRequest.FromString, response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.ListResourcesResponse.SerializeToString, ), + 'AttachResource': grpc.unary_unary_rpc_method_handler( + servicer.AttachResource, + request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.AttachResourceRequest.FromString, + response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.AttachResourceResponse.SerializeToString, + ), + 'DetachResource': grpc.unary_unary_rpc_method_handler( + servicer.DetachResource, + request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.DetachResourceRequest.FromString, + response_serializer=host_dot_authority_dot_v1_dot_authority__pb2.DetachResourceResponse.SerializeToString, + ), 'AuthorizeTransfer': grpc.unary_unary_rpc_method_handler( servicer.AuthorizeTransfer, request_deserializer=host_dot_authority_dot_v1_dot_authority__pb2.AuthorizeTransferRequest.FromString, @@ -323,6 +389,60 @@ def ListResources(request, metadata, _registered_method=True) + @staticmethod + def AttachResource(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/host.authority.v1.AuthorityService/AttachResource', + host_dot_authority_dot_v1_dot_authority__pb2.AttachResourceRequest.SerializeToString, + host_dot_authority_dot_v1_dot_authority__pb2.AttachResourceResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def DetachResource(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/host.authority.v1.AuthorityService/DetachResource', + host_dot_authority_dot_v1_dot_authority__pb2.DetachResourceRequest.SerializeToString, + host_dot_authority_dot_v1_dot_authority__pb2.DetachResourceResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def AuthorizeTransfer(request, target, diff --git a/libraries/authority-client/python/authority_client/_gen/host/composition/v1/composition_pb2.py b/libraries/authority-client/python/authority_client/_gen/host/composition/v1/composition_pb2.py index 94c22ac4..75733b91 100644 --- a/libraries/authority-client/python/authority_client/_gen/host/composition/v1/composition_pb2.py +++ b/libraries/authority-client/python/authority_client/_gen/host/composition/v1/composition_pb2.py @@ -25,7 +25,7 @@ from authority_client._gen.host.authority.v1 import work_context_pb2 as host_dot_authority_dot_v1_dot_work__context__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%host/composition/v1/composition.proto\x12\x13host.composition.v1\x1a$host/authority/v1/work_context.proto\"\xff\x01\n\x14\x41pplyManifestRequest\x12\x41\n\x08manifest\x18\x01 \x01(\x0b\x32%.host.composition.v1.ModuleManifestV1R\x08manifest\x12?\n\x07\x63\x61rrier\x18\x02 \x01(\x0b\x32%.host.composition.v1.SignedManifestV1R\x07\x63\x61rrier\x12\x1e\n\ncredential\x18\x03 \x01(\tR\ncredential\x12\x43\n\tauthority\x18\x04 \x01(\x0b\x32%.host.composition.v1.SignedManifestV1R\tauthority\"\xd9\x02\n\x13\x41uthorityManifestV1\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x1e\n\ngeneration\x18\x02 \x01(\x04R\ngeneration\x12\x30\n\x14\x63ore_manifest_digest\x18\x03 \x01(\tR\x12\x63oreManifestDigest\x12=\n\x07methods\x18\x04 \x03(\x0b\x32#.host.composition.v1.MethodPolicyV1R\x07methods\x12\x45\n\x0escope_ceilings\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\rscopeCeilings\x12\x31\n\x05roles\x18\x06 \x03(\x0b\x32\x1b.host.composition.v1.RoleV1R\x05roles\x12\x1f\n\x0bowner_aware\x18\x07 \x01(\x08R\nownerAware\"\x92\x01\n\x0eMethodPolicyV1\x12\x16\n\x06method\x18\x01 \x01(\tR\x06method\x12:\n\x08requires\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x08requires\x12,\n\x12\x61ny_checked_caller\x18\x03 \x01(\x08R\x10\x61nyCheckedCaller\"J\n\x10SignedManifestV1\x12\x1a\n\x08\x64ocument\x18\x01 \x01(\x0cR\x08\x64ocument\x12\x1a\n\x08\x65nvelope\x18\x02 \x01(\x0cR\x08\x65nvelope\"\x80\x01\n\x15\x41pplyManifestResponse\x12\x1c\n\tprincipal\x18\x01 \x01(\tR\tprincipal\x12\x1e\n\ngeneration\x18\x02 \x01(\x04R\ngeneration\x12)\n\x10installation_ids\x18\x03 \x03(\tR\x0finstallationIds\"\xe8\x02\n\x10ModuleManifestV1\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x1c\n\tprincipal\x18\x02 \x01(\tR\tprincipal\x12\x12\n\x04kind\x18\x03 \x01(\tR\x04kind\x12\x1e\n\ngeneration\x18\x04 \x01(\x04R\ngeneration\x12:\n\x08\x62indings\x18\x05 \x03(\x0b\x32\x1e.host.composition.v1.BindingV1R\x08\x62indings\x12J\n\x0eresource_kinds\x18\x06 \x03(\x0b\x32#.host.composition.v1.ResourceKindV1R\rresourceKinds\x12/\n\x04page\x18\x07 \x01(\x0b\x32\x1b.host.composition.v1.PageV1R\x04page\x12\x31\n\x05roles\x18\x08 \x03(\x0b\x32\x1b.host.composition.v1.RoleV1R\x05roles\"T\n\x06RoleV1\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\"\x8a\x02\n\tBindingV1\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x43\n\rinvoke_scopes\x18\x03 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0cinvokeScopes\x12G\n\x0fheadless_scopes\x18\x04 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0eheadlessScopes\x12\x43\n\rlookup_scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0clookupScopes\">\n\x0eResourceKindV1\x12\x12\n\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n\x07\x61\x63tions\x18\x02 \x03(\tR\x07\x61\x63tions\"4\n\x06PageV1\x12\x14\n\x05title\x18\x01 \x01(\tR\x05title\x12\x14\n\x05order\x18\x02 \x01(\x05R\x05order2|\n\x12\x43ompositionService\x12\x66\n\rApplyManifest\x12).host.composition.v1.ApplyManifestRequest\x1a*.host.composition.v1.ApplyManifestResponseB3Z1gateway/pkg/gen/host/composition/v1;compositionv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%host/composition/v1/composition.proto\x12\x13host.composition.v1\x1a$host/authority/v1/work_context.proto\"\xff\x01\n\x14\x41pplyManifestRequest\x12\x41\n\x08manifest\x18\x01 \x01(\x0b\x32%.host.composition.v1.ModuleManifestV1R\x08manifest\x12?\n\x07\x63\x61rrier\x18\x02 \x01(\x0b\x32%.host.composition.v1.SignedManifestV1R\x07\x63\x61rrier\x12\x1e\n\ncredential\x18\x03 \x01(\tR\ncredential\x12\x43\n\tauthority\x18\x04 \x01(\x0b\x32%.host.composition.v1.SignedManifestV1R\tauthority\"\xd9\x02\n\x13\x41uthorityManifestV1\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x1e\n\ngeneration\x18\x02 \x01(\x04R\ngeneration\x12\x30\n\x14\x63ore_manifest_digest\x18\x03 \x01(\tR\x12\x63oreManifestDigest\x12=\n\x07methods\x18\x04 \x03(\x0b\x32#.host.composition.v1.MethodPolicyV1R\x07methods\x12\x45\n\x0escope_ceilings\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\rscopeCeilings\x12\x31\n\x05roles\x18\x06 \x03(\x0b\x32\x1b.host.composition.v1.RoleV1R\x05roles\x12\x1f\n\x0bowner_aware\x18\x07 \x01(\x08R\nownerAware\"\x92\x01\n\x0eMethodPolicyV1\x12\x16\n\x06method\x18\x01 \x01(\tR\x06method\x12:\n\x08requires\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x08requires\x12,\n\x12\x61ny_checked_caller\x18\x03 \x01(\x08R\x10\x61nyCheckedCaller\"J\n\x10SignedManifestV1\x12\x1a\n\x08\x64ocument\x18\x01 \x01(\x0cR\x08\x64ocument\x12\x1a\n\x08\x65nvelope\x18\x02 \x01(\x0cR\x08\x65nvelope\"\x80\x01\n\x15\x41pplyManifestResponse\x12\x1c\n\tprincipal\x18\x01 \x01(\tR\tprincipal\x12\x1e\n\ngeneration\x18\x02 \x01(\x04R\ngeneration\x12)\n\x10installation_ids\x18\x03 \x03(\tR\x0finstallationIds\"\xe8\x02\n\x10ModuleManifestV1\x12\x16\n\x06module\x18\x01 \x01(\tR\x06module\x12\x1c\n\tprincipal\x18\x02 \x01(\tR\tprincipal\x12\x12\n\x04kind\x18\x03 \x01(\tR\x04kind\x12\x1e\n\ngeneration\x18\x04 \x01(\x04R\ngeneration\x12:\n\x08\x62indings\x18\x05 \x03(\x0b\x32\x1e.host.composition.v1.BindingV1R\x08\x62indings\x12J\n\x0eresource_kinds\x18\x06 \x03(\x0b\x32#.host.composition.v1.ResourceKindV1R\rresourceKinds\x12/\n\x04page\x18\x07 \x01(\x0b\x32\x1b.host.composition.v1.PageV1R\x04page\x12\x31\n\x05roles\x18\x08 \x03(\x0b\x32\x1b.host.composition.v1.RoleV1R\x05roles\"\xb0\x01\n\x06RoleV1\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x06scopes\x18\x02 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x06scopes\x12Z\n\x19installation_owned_scopes\x18\x03 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x17installationOwnedScopes\"\x8a\x02\n\tBindingV1\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n\x08\x61udience\x18\x02 \x01(\tR\x08\x61udience\x12\x43\n\rinvoke_scopes\x18\x03 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0cinvokeScopes\x12G\n\x0fheadless_scopes\x18\x04 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0eheadlessScopes\x12\x43\n\rlookup_scopes\x18\x05 \x03(\x0b\x32\x1e.host.authority.v1.WorkScopeV1R\x0clookupScopes\">\n\x0eResourceKindV1\x12\x12\n\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n\x07\x61\x63tions\x18\x02 \x03(\tR\x07\x61\x63tions\"4\n\x06PageV1\x12\x14\n\x05title\x18\x01 \x01(\tR\x05title\x12\x14\n\x05order\x18\x02 \x01(\x05R\x05order2|\n\x12\x43ompositionService\x12\x66\n\rApplyManifest\x12).host.composition.v1.ApplyManifestRequest\x1a*.host.composition.v1.ApplyManifestResponseB3Z1gateway/pkg/gen/host/composition/v1;compositionv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -45,14 +45,14 @@ _globals['_APPLYMANIFESTRESPONSE']._serialized_end=1060 _globals['_MODULEMANIFESTV1']._serialized_start=1063 _globals['_MODULEMANIFESTV1']._serialized_end=1423 - _globals['_ROLEV1']._serialized_start=1425 - _globals['_ROLEV1']._serialized_end=1509 - _globals['_BINDINGV1']._serialized_start=1512 - _globals['_BINDINGV1']._serialized_end=1778 - _globals['_RESOURCEKINDV1']._serialized_start=1780 - _globals['_RESOURCEKINDV1']._serialized_end=1842 - _globals['_PAGEV1']._serialized_start=1844 - _globals['_PAGEV1']._serialized_end=1896 - _globals['_COMPOSITIONSERVICE']._serialized_start=1898 - _globals['_COMPOSITIONSERVICE']._serialized_end=2022 + _globals['_ROLEV1']._serialized_start=1426 + _globals['_ROLEV1']._serialized_end=1602 + _globals['_BINDINGV1']._serialized_start=1605 + _globals['_BINDINGV1']._serialized_end=1871 + _globals['_RESOURCEKINDV1']._serialized_start=1873 + _globals['_RESOURCEKINDV1']._serialized_end=1935 + _globals['_PAGEV1']._serialized_start=1937 + _globals['_PAGEV1']._serialized_end=1989 + _globals['_COMPOSITIONSERVICE']._serialized_start=1991 + _globals['_COMPOSITIONSERVICE']._serialized_end=2115 # @@protoc_insertion_point(module_scope) 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 42761d18..ca93200d 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("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCK7AQoYQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkSFQoNZnJvbV9vd25lcl9pZBgFIAEoCRIXCg90b19wcmluY2lwYWxfaWQYBiABKAkiSAoZQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRIUCgxwcmluY2lwYWxfaWQYASABKAkSFQoNYXV0aG9yaXplZF9ieRgCIAEoCSJ1Cg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAEhkKEXJlc291cmNlX293bmVyX2lkGAUgASgJQgcKBW1hdGNoMpwGChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USbgoRQXV0aG9yaXplVHJhbnNmZXISKy5ob3N0LmF1dGhvcml0eS52MS5BdXRob3JpemVUcmFuc2ZlclJlcXVlc3QaLC5ob3N0LmF1dGhvcml0eS52MS5BdXRob3JpemVUcmFuc2ZlclJlc3BvbnNlEk0KCERlbGVnYXRlEiIuaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGVSZXF1ZXN0Gh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbhJrChBSZXZva2VEZWxlZ2F0aW9uEiouaG9zdC5hdXRob3JpdHkudjEuUmV2b2tlRGVsZWdhdGlvblJlcXVlc3QaKy5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVzcG9uc2USVwoNR2V0RGVsZWdhdGlvbhInLmhvc3QuYXV0aG9yaXR5LnYxLkdldERlbGVnYXRpb25SZXF1ZXN0Gh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbkIvWi1nYXRld2F5L3BrZy9nZW4vaG9zdC9hdXRob3JpdHkvdjE7YXV0aG9yaXR5djFiBnByb3RvMw", [file_google_api_annotations, file_host_authority_v1_work_context]); + fileDesc("CiFob3N0L2F1dGhvcml0eS92MS9hdXRob3JpdHkucHJvdG8SEWhvc3QuYXV0aG9yaXR5LnYxIuYDCgxJc3N1ZVJlcXVlc3QSMgoGcGVyc29uGAEgASgLMiAuaG9zdC5hdXRob3JpdHkudjEuUGVyc29uU2Vzc2lvbkgAEjkKCHdvcmtsb2FkGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsSAASOgoMaW5zdGFsbGF0aW9uGAMgASgLMiIuaG9zdC5hdXRob3JpdHkudjEuSW5zdGFsbGF0aW9uUmVmSAASFgoMcGFyZW50X3Rva2VuGAQgASgJSAASNgoKZGVsZWdhdGlvbhgLIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLkRlbGVnYXRpb25SZWZIABIQCghhdWRpZW5jZRgFIAEoCRIuCgZzY29wZXMYBiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRITCgt0dGxfc2Vjb25kcxgHIAEoAxIcChRvcGVyYXRpb25fYmluZGluZ19pZBgIIAEoCRIXCg9vcmdhbml6YXRpb25faWQYCSABKAkSNAoFYWN0b3IYCiABKAsyJS5ob3N0LmF1dGhvcml0eS52MS5Xb3JrbG9hZENyZWRlbnRpYWwSEQoJb3BlcmF0aW9uGAwgASgJQgQKAmFzIiYKDURlbGVnYXRpb25SZWYSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCSJiCg9EZWxlZ2F0ZVJlcXVlc3QSDgoGbW9kdWxlGAEgASgJEi4KBnNjb3BlcxgCIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEg8KB3B1cnBvc2UYAyABKAkirgEKCkRlbGVnYXRpb24SFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSDwoHZ3JhbnRvchgDIAEoCRIOCgZtb2R1bGUYBCABKAkSLgoGc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESDwoHcHVycG9zZRgGIAEoCRIOCgZhY3RpdmUYByABKAgiYwoUR2V0RGVsZWdhdGlvblJlcXVlc3QSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRI0CgVhY3RvchgCIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbCIwChdSZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBIVCg1kZWxlZ2F0aW9uX2lkGAEgASgJIk0KGFJldm9rZURlbGVnYXRpb25SZXNwb25zZRIxCgpkZWxlZ2F0aW9uGAEgASgLMh0uaG9zdC5hdXRob3JpdHkudjEuRGVsZWdhdGlvbiIhCg1QZXJzb25TZXNzaW9uSgQIARACUgpzZXNzaW9uX2lkIigKEldvcmtsb2FkQ3JlZGVudGlhbBISCgpjcmVkZW50aWFsGAEgASgJIioKD0luc3RhbGxhdGlvblJlZhIXCg9pbnN0YWxsYXRpb25faWQYASABKAkiagoNSXNzdWVSZXNwb25zZRINCgV0b2tlbhgBIAEoCRIXCg9leHBpcmVzX2F0X3VuaXgYAiABKAMSMQoHY29udGV4dBgDIAEoCzIgLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtDb250ZXh0VjEiUQoMQ2hlY2tSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIQCghhZHZpc29yeRgEIAEoCCJzCg1DaGVja1Jlc3BvbnNlEjEKB2NvbnRleHQYASABKAsyIC5ob3N0LmF1dGhvcml0eS52MS5Xb3JrQ29udGV4dFYxEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YAiABKAQSDwoHcmVmdXNhbBgDIAEoCSINCgtLZXlzUmVxdWVzdCJKCgxLZXlzUmVzcG9uc2USDgoGaXNzdWVyGAEgASgJEioKBGtleXMYAiADKAsyHC5ob3N0LmF1dGhvcml0eS52MS5QdWJsaWNLZXkiQgoJUHVibGljS2V5Eg4KBmtleV9pZBgBIAEoCRIRCglhbGdvcml0aG0YAiABKAkSEgoKcHVibGljX2tleRgDIAEoDCJeChRMaXN0UmVzb3VyY2VzUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhdWRpZW5jZRgCIAEoCRIVCg1yZXNvdXJjZV9raW5kGAMgASgJEg4KBmFjdGlvbhgEIAEoCSJJChVMaXN0UmVzb3VyY2VzUmVzcG9uc2USMAoGZ3JhbnRzGAEgAygLMiAuaG9zdC5hdXRob3JpdHkudjEuUmVzb3VyY2VHcmFudCKIAQoVQXR0YWNoUmVzb3VyY2VSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkiMQoWQXR0YWNoUmVzb3VyY2VSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkikgEKFURldGFjaFJlc291cmNlUmVxdWVzdBI0CgVhY3RvchgBIAEoCzIlLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtsb2FkQ3JlZGVudGlhbBIXCg9vcmdhbml6YXRpb25faWQYAiABKAkSFQoNcmVzb3VyY2Vfa2luZBgDIAEoCRITCgtyZXNvdXJjZV9pZBgEIAEoCSIxChZEZXRhY2hSZXNvdXJjZVJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCSK7AQoYQXV0aG9yaXplVHJhbnNmZXJSZXF1ZXN0Eg0KBXRva2VuGAEgASgJEjQKBWFjdG9yGAIgASgLMiUuaG9zdC5hdXRob3JpdHkudjEuV29ya2xvYWRDcmVkZW50aWFsEhUKDXJlc291cmNlX2tpbmQYAyABKAkSEwoLcmVzb3VyY2VfaWQYBCABKAkSFQoNZnJvbV9vd25lcl9pZBgFIAEoCRIXCg90b19wcmluY2lwYWxfaWQYBiABKAkiSAoZQXV0aG9yaXplVHJhbnNmZXJSZXNwb25zZRIUCgxwcmluY2lwYWxfaWQYASABKAkSFQoNYXV0aG9yaXplZF9ieRgCIAEoCSJ1Cg1SZXNvdXJjZUdyYW50Eg0KA2FsbBgBIAEoCEgAEgwKAmlkGAIgASgJSAASEAoGcHJlZml4GAMgASgJSAASEQoHcGF0dGVybhgEIAEoCUgAEhkKEXJlc291cmNlX293bmVyX2lkGAUgASgJQgcKBW1hdGNoMuoHChBBdXRob3JpdHlTZXJ2aWNlEmoKBUlzc3VlEh8uaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuSXNzdWVSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5Omlzc3VlEmoKBUNoZWNrEh8uaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXF1ZXN0GiAuaG9zdC5hdXRob3JpdHkudjEuQ2hlY2tSZXNwb25zZSIegtPkkwIYOgEqIhMvdjEvYXV0aG9yaXR5OmNoZWNrEkcKBEtleXMSHi5ob3N0LmF1dGhvcml0eS52MS5LZXlzUmVxdWVzdBofLmhvc3QuYXV0aG9yaXR5LnYxLktleXNSZXNwb25zZRJiCg1MaXN0UmVzb3VyY2VzEicuaG9zdC5hdXRob3JpdHkudjEuTGlzdFJlc291cmNlc1JlcXVlc3QaKC5ob3N0LmF1dGhvcml0eS52MS5MaXN0UmVzb3VyY2VzUmVzcG9uc2USZQoOQXR0YWNoUmVzb3VyY2USKC5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlcXVlc3QaKS5ob3N0LmF1dGhvcml0eS52MS5BdHRhY2hSZXNvdXJjZVJlc3BvbnNlEmUKDkRldGFjaFJlc291cmNlEiguaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXF1ZXN0GikuaG9zdC5hdXRob3JpdHkudjEuRGV0YWNoUmVzb3VyY2VSZXNwb25zZRJuChFBdXRob3JpemVUcmFuc2ZlchIrLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVxdWVzdBosLmhvc3QuYXV0aG9yaXR5LnYxLkF1dGhvcml6ZVRyYW5zZmVyUmVzcG9uc2USTQoIRGVsZWdhdGUSIi5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0ZVJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uEmsKEFJldm9rZURlbGVnYXRpb24SKi5ob3N0LmF1dGhvcml0eS52MS5SZXZva2VEZWxlZ2F0aW9uUmVxdWVzdBorLmhvc3QuYXV0aG9yaXR5LnYxLlJldm9rZURlbGVnYXRpb25SZXNwb25zZRJXCg1HZXREZWxlZ2F0aW9uEicuaG9zdC5hdXRob3JpdHkudjEuR2V0RGVsZWdhdGlvblJlcXVlc3QaHS5ob3N0LmF1dGhvcml0eS52MS5EZWxlZ2F0aW9uQi9aLWdhdGV3YXkvcGtnL2dlbi9ob3N0L2F1dGhvcml0eS92MTthdXRob3JpdHl2MWIGcHJvdG8z", [file_google_api_annotations, file_host_authority_v1_work_context]); /** * IssueRequest names on whose behalf the capability is issued. @@ -647,6 +647,132 @@ 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); + +/** + * 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); + /** * AuthorizeTransferRequest names one existing resource and where it moves. * @@ -702,7 +828,7 @@ export type AuthorizeTransferRequest = Message<"host.authority.v1.AuthorizeTrans * Use `create(AuthorizeTransferRequestSchema)` to create a new message. */ export const AuthorizeTransferRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 18); + messageDesc(file_host_authority_v1_authority, 22); /** * AuthorizeTransferResponse is the decision the store records. @@ -730,7 +856,7 @@ export type AuthorizeTransferResponse = Message<"host.authority.v1.AuthorizeTran * Use `create(AuthorizeTransferResponseSchema)` to create a new message. */ export const AuthorizeTransferResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_host_authority_v1_authority, 19); + messageDesc(file_host_authority_v1_authority, 23); /** * ResourceGrant is one way of naming covered resources. Exactly one match is @@ -791,7 +917,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, 24); /** * AuthorityService is implemented by the host (accounts) and by the local authority. @@ -847,6 +973,52 @@ export const AuthorityService: GenService<{ input: typeof ListResourcesRequestSchema; output: typeof ListResourcesResponseSchema; }, + /** + * 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 "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 + * 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. 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 + */ + attachResource: { + methodKind: "unary"; + 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; + }, /** * AuthorizeTransfer decides whether an existing resource may join an * installed solution's principal. The caller is the kind's one serving @@ -858,8 +1030,14 @@ export const AuthorityService: GenService<{ * 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. * * @generated from rpc host.authority.v1.AuthorityService.AuthorizeTransfer */ 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 0eb49499..37f58c04 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("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIoYCChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMRITCgtvd25lcl9hd2FyZRgHIAEoCCJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSJGCgZSb2xlVjESDAoEbmFtZRgBIAEoCRIuCgZzY29wZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSLQAQoJQmluZGluZ1YxEgoKAmlkGAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEjUKDWludm9rZV9zY29wZXMYAyADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI3Cg9oZWFkbGVzc19zY29wZXMYBCADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRI1Cg1sb29rdXBfc2NvcGVzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjEiLwoOUmVzb3VyY2VLaW5kVjESDAoEa2luZBgBIAEoCRIPCgdhY3Rpb25zGAIgAygJIiYKBlBhZ2VWMRINCgV0aXRsZRgBIAEoCRINCgVvcmRlchgCIAEoBTJ8ChJDb21wb3NpdGlvblNlcnZpY2USZgoNQXBwbHlNYW5pZmVzdBIpLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlcXVlc3QaKi5ob3N0LmNvbXBvc2l0aW9uLnYxLkFwcGx5TWFuaWZlc3RSZXNwb25zZUIzWjFnYXRld2F5L3BrZy9nZW4vaG9zdC9jb21wb3NpdGlvbi92MTtjb21wb3NpdGlvbnYxYgZwcm90bzM", [file_host_authority_v1_work_context]); + fileDesc("CiVob3N0L2NvbXBvc2l0aW9uL3YxL2NvbXBvc2l0aW9uLnByb3RvEhNob3N0LmNvbXBvc2l0aW9uLnYxItUBChRBcHBseU1hbmlmZXN0UmVxdWVzdBI3CghtYW5pZmVzdBgBIAEoCzIlLmhvc3QuY29tcG9zaXRpb24udjEuTW9kdWxlTWFuaWZlc3RWMRI2CgdjYXJyaWVyGAIgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxEhIKCmNyZWRlbnRpYWwYAyABKAkSOAoJYXV0aG9yaXR5GAQgASgLMiUuaG9zdC5jb21wb3NpdGlvbi52MS5TaWduZWRNYW5pZmVzdFYxIoYCChNBdXRob3JpdHlNYW5pZmVzdFYxEg4KBm1vZHVsZRgBIAEoCRISCgpnZW5lcmF0aW9uGAIgASgEEhwKFGNvcmVfbWFuaWZlc3RfZGlnZXN0GAMgASgJEjQKB21ldGhvZHMYBCADKAsyIy5ob3N0LmNvbXBvc2l0aW9uLnYxLk1ldGhvZFBvbGljeVYxEjYKDnNjb3BlX2NlaWxpbmdzGAUgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESKgoFcm9sZXMYBiADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMRITCgtvd25lcl9hd2FyZRgHIAEoCCJuCg5NZXRob2RQb2xpY3lWMRIOCgZtZXRob2QYASABKAkSMAoIcmVxdWlyZXMYAiADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMRIaChJhbnlfY2hlY2tlZF9jYWxsZXIYAyABKAgiNgoQU2lnbmVkTWFuaWZlc3RWMRIQCghkb2N1bWVudBgBIAEoDBIQCghlbnZlbG9wZRgCIAEoDCJYChVBcHBseU1hbmlmZXN0UmVzcG9uc2USEQoJcHJpbmNpcGFsGAEgASgJEhIKCmdlbmVyYXRpb24YAiABKAQSGAoQaW5zdGFsbGF0aW9uX2lkcxgDIAMoCSKdAgoQTW9kdWxlTWFuaWZlc3RWMRIOCgZtb2R1bGUYASABKAkSEQoJcHJpbmNpcGFsGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKZ2VuZXJhdGlvbhgEIAEoBBIwCghiaW5kaW5ncxgFIAMoCzIeLmhvc3QuY29tcG9zaXRpb24udjEuQmluZGluZ1YxEjsKDnJlc291cmNlX2tpbmRzGAYgAygLMiMuaG9zdC5jb21wb3NpdGlvbi52MS5SZXNvdXJjZUtpbmRWMRIpCgRwYWdlGAcgASgLMhsuaG9zdC5jb21wb3NpdGlvbi52MS5QYWdlVjESKgoFcm9sZXMYCCADKAsyGy5ob3N0LmNvbXBvc2l0aW9uLnYxLlJvbGVWMSKJAQoGUm9sZVYxEgwKBG5hbWUYASABKAkSLgoGc2NvcGVzGAIgAygLMh4uaG9zdC5hdXRob3JpdHkudjEuV29ya1Njb3BlVjESQQoZaW5zdGFsbGF0aW9uX293bmVkX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxItABCglCaW5kaW5nVjESCgoCaWQYASABKAkSEAoIYXVkaWVuY2UYAiABKAkSNQoNaW52b2tlX3Njb3BlcxgDIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjcKD2hlYWRsZXNzX3Njb3BlcxgEIAMoCzIeLmhvc3QuYXV0aG9yaXR5LnYxLldvcmtTY29wZVYxEjUKDWxvb2t1cF9zY29wZXMYBSADKAsyHi5ob3N0LmF1dGhvcml0eS52MS5Xb3JrU2NvcGVWMSIvCg5SZXNvdXJjZUtpbmRWMRIMCgRraW5kGAEgASgJEg8KB2FjdGlvbnMYAiADKAkiJgoGUGFnZVYxEg0KBXRpdGxlGAEgASgJEg0KBW9yZGVyGAIgASgFMnwKEkNvbXBvc2l0aW9uU2VydmljZRJmCg1BcHBseU1hbmlmZXN0EikuaG9zdC5jb21wb3NpdGlvbi52MS5BcHBseU1hbmlmZXN0UmVxdWVzdBoqLmhvc3QuY29tcG9zaXRpb24udjEuQXBwbHlNYW5pZmVzdFJlc3BvbnNlQjNaMWdhdGV3YXkvcGtnL2dlbi9ob3N0L2NvbXBvc2l0aW9uL3YxO2NvbXBvc2l0aW9udjFiBnByb3RvMw", [file_host_authority_v1_work_context]); /** * ApplyManifestRequest carries the manifest. @@ -334,6 +334,20 @@ 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 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 + * 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/authority-core/go/authority/attachment.go b/libraries/authority-core/go/authority/attachment.go new file mode 100644 index 00000000..5663e01f --- /dev/null +++ b/libraries/authority-core/go/authority/attachment.go @@ -0,0 +1,241 @@ +package authority + +import ( + "context" + "errors" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "slices" + "strings" + + "github.com/codefly-dev/authority/libraries/authority-core/go/decide" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" +) + +// MaxOwnedPerKind bounds the exact ids carried by an installation-owned grant. +const MaxOwnedPerKind = 32 + +// AttachmentRequest presents the capability to the attested serving module. +// The owner is derived from verified provenance, never a caller-supplied id. +type AttachmentRequest struct { + Token, Kind, ResourceID string + Store Workload +} + +// AttachResource attaches an id through one installation. Verification precedes +// the transaction; revision, current provenance and exclusivity are decided +// together under the store's write transaction. +func (s *Service) AttachResource(ctx context.Context, r AttachmentRequest) (string, error) { + if err := decide.ValidResourceID(r.ResourceID); err != nil { + return "", err + } + var actor domain.Module + err := s.store.Read(ctx, func(tx Tx) (err error) { actor, err = attachmentServer(tx, r.Store, r.Kind); return err }) + if err != nil { + return "", err + } + verified, err := s.Verify(ctx, r.Token, actor.Principal) + if err != nil { + return "", err + } + 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 + err = s.store.Write(ctx, func(tx Tx) error { + owner = "" + current, err := attachmentServer(tx, r.Store, r.Kind) + if err != nil { + return err + } + if current.Principal != actor.Principal || current.Generation != actor.Generation { + return domain.Refuse("serving module changed during attachment") + } + if err := s.attachmentFresh(tx, wc); err != nil { + return err + } + candidates := []string{} + if inst, err := tx.InstallationByPrincipal(wc.GetOwnerPrincipalId()); err == nil { + candidates = append(candidates, inst.ID) + } else if !errors.Is(err, domain.ErrNotFound) { + return err + } + for _, principal := range solutionPrincipals(wc, actor.Principal) { + m, err := tx.Module(principal) + if err != nil { + return notFound(err) + } + if m.Kind == domain.KindSolution { + inst, err := installationOf(tx, wc.GetTenantId(), m.Name) + if err != nil { + return err + } + candidates = append(candidates, inst.ID) + } + } + slices.Sort(candidates) + candidates = slices.Compact(candidates) + if len(candidates) == 0 { + return domain.Refuse("the capability was issued through no installation") + } + if len(candidates) != 1 { + return domain.Refuse("the capability was issued through %d installations (%s); an attachment has exactly one owner", len(candidates), strings.Join(candidates, ", ")) + } + inst, err := tx.Installation(candidates[0]) + if err != nil { + return err + } + 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, inst.PrincipalID) { + return domain.Refuse("the capability holds no create for this installation; existing resources require AuthorizeTransfer") + } + if err := attachOwned(tx, inst, r.Kind, r.ResourceID); err != nil { + return err + } + owner = inst.ID + return nil + }) + return owner, err +} + +// DetachResource releases the serving module's deleted resource and invalidates +// previously issued capabilities. The bound counts only currently attached ids. +func (s *Service) DetachResource(ctx context.Context, caller Workload, org, kind, id string) (string, error) { + if err := decide.ValidResourceID(id); err != nil { + return "", err + } + if org == "" { + return "", domain.Malformed("a detachment names the organization the resource belongs to") + } + var owner string + err := s.store.Write(ctx, func(tx Tx) error { + owner = "" + if _, err := attachmentServer(tx, caller, kind); err != nil { + return err + } + if _, err := tx.Organization(org); err != nil { + return notFound(err) + } + all, err := tx.Installations(org) + if err != nil { + return err + } + for _, inst := range all { + if !slices.Contains(inst.Owned[kind], id) { + continue + } + inst.Owned[kind] = slices.DeleteFunc(inst.Owned[kind], func(held string) bool { return held == id }) + inst.Revision++ + if err := tx.PutInstallation(inst); err != nil { + return err + } + if err := tx.BumpAuthorizationRevision(org); err != nil { + return err + } + owner = inst.ID + return nil + } + return nil + }) + return owner, err +} + +func attachmentServer(tx Tx, caller Workload, kind string) (domain.Module, error) { + m, err := tx.ModuleNamed(caller.Module) + if err != nil { + return m, notFound(err) + } + servers, err := servingModules(tx, kind) + if err != nil { + return m, err + } + slices.Sort(servers) + if !slices.Contains(servers, m.Name) { + return m, domain.Refuse("%s does not serve %s, so it attaches none of its resources", m.Name, kind) + } + if len(servers) != 1 { + return m, domain.Refuse("%s is served by %s; an attachment needs the kind's one serving module", kind, strings.Join(servers, ", ")) + } + return m, nil +} + +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")) + }) +} + +// 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 new file mode 100644 index 00000000..f561509b --- /dev/null +++ b/libraries/authority-core/go/authority/attachment_test.go @@ -0,0 +1,297 @@ +package authority_test + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" +) + +// Present real Core-signed capabilities, including the owner-free scope shape +// 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() + minter := workcontext.Authority{Issuer: h.cfg.Issuer, KeyID: h.cfg.KeyID, Signer: h.cfg.Signer, Seals: h.svc.Seals(), Revisions: h.svc.Revisions(), Now: h.cfg.Clock} + audience := "store" + if len(targets) > 0 { + audience = targets[0] + } + token, _, err := minter.Start(h.ctx, workcontext.StartInput{TenantID: acme, OrganizationID: acme, OwnerPrincipalID: "alex", OwnerPrincipalKind: "human", TaskID: "attach-test", SessionID: "session", Audience: audience, AuthorityScopes: scopes, InstallationID: h.inst(acme, audience).ID, TTL: time.Minute, ReplayPolicy: workcontext.ReplayIdempotent}) + if err != nil { + t.Fatal(err) + } + for index, target := range targets { + parent, err := h.svc.Verify(h.ctx, token, audience) + if err != nil { + t.Fatal(err) + } + audience = "store" + if index+1 < len(targets) { + audience = targets[index+1] + } + token, _, err = minter.Child(h.ctx, parent, workcontext.ChildInput{PrincipalID: target, PrincipalKind: "service", OrganizationID: acme, DelegationID: "session", Audience: audience, GrantedScopes: scopes, TTL: time.Minute}) + if err != nil { + t.Fatal(err) + } + } + return token +} + +func attach(h *harness, token, id string) (string, error) { + return h.svc.AttachResource(h.ctx, authority.AttachmentRequest{Token: token, Store: authority.Workload{Module: "store"}, Kind: kind, ResourceID: id}) +} + +func TestAttachmentRefusalsAndExclusiveBoundedLifecycle(t *testing.T) { + h := newHarness(t) + create := func(target string) string { return attachmentToken(t, h, []string{target}, sc(kind, "create")) } + token := create("a") + for _, id := range []string{"", " x", "x ", "x\n", "x\r", "x\t", "x\x00", "*", "x*", strings.Repeat("x", 129)} { + _, err := attach(h, token, id) + 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 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"}, + } { + t.Run(tc.name, func(t *testing.T) { _, err := attach(h, tc.token, "x"); refused(t, err, tc.reason) }) + } + _, err := h.svc.AttachResource(h.ctx, authority.AttachmentRequest{Token: token, Store: authority.Workload{Module: "a"}, Kind: kind, ResourceID: "x"}) + refused(t, err, "does not serve") + wrong := h.must(h.root("ada", "store")) + _, err = h.svc.AttachResource(h.ctx, authority.AttachmentRequest{Token: wrong.Token, Store: authority.Workload{Module: "a"}, Kind: "a.note", ResourceID: "x"}) + refused(t, err, "audience") + var before domain.Installation + if err := h.store.Read(h.ctx, func(tx authority.Tx) (err error) { before, err = tx.Installation(h.inst(acme, "a").ID); return err }); err != nil { + t.Fatal(err) + } + for range 2 { + owner, err := attach(h, token, "x") + if err != nil || owner != before.ID { + t.Fatalf("attach: %s %v", owner, err) + } + } + if len(before.Owned) != 0 { + t.Fatal("mutated an earlier snapshot") + } + _, err = attach(h, create("b"), "x") + refused(t, err, "owned by") + admin := attachmentToken(t, h, []string{"b"}, owned(kind, "", []string{"admin"}, "x")) + _, err = attach(h, admin, "x") + 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) + } + } + _, err = attach(h, token, "extra") + refused(t, err, "already owns 32") + if _, err := h.svc.DetachResource(h.ctx, authority.Workload{Module: "a"}, acme, kind, "x"); err == nil { + t.Fatal("non-server detached") + } + if _, err := h.svc.DetachResource(h.ctx, authority.Workload{Module: "store"}, acme, kind, "x"); err != nil { + t.Fatal(err) + } + _, err = attach(h, token, "extra") + if err == nil { + t.Fatal("pre-detach capability stayed valid") + } + token = create("a") + if _, err := attach(h, token, "extra"); err != nil { + t.Fatal(err) + } + if id, err := h.svc.DetachResource(h.ctx, authority.Workload{Module: "store"}, acme, kind, "absent"); err != nil || id != "" { + t.Fatalf("absent detach: %s %v", id, err) + } + if err := h.svc.Uninstall(h.ctx, h.person("alex"), acme, before.ID); err != nil { + t.Fatal(err) + } + h.install(acme, "a") + _, err = attach(h, token, "late") + if err == nil { + t.Fatal("pre-reinstall capability stayed valid") + } + if err := h.store.Read(h.ctx, func(tx authority.Tx) error { + inst, err := tx.Installation(before.ID) + if err == nil && len(inst.Owned) != 0 { + t.Fatal("reinstall retained owned ids") + } + return err + }); err != nil { + t.Fatal(err) + } + admin = attachmentToken(t, h, []string{"a"}, owned(kind, "", []string{"admin"}, "existing")) + _, err = attach(h, admin, "existing") + refused(t, err, "AuthorizeTransfer") +} + +func TestConcurrentAttachmentsHaveExactlyOneOwner(t *testing.T) { + h := newHarness(t) + tokens := []string{attachmentToken(t, h, []string{"a"}, sc(kind, "create")), attachmentToken(t, h, []string{"b"}, sc(kind, "create"))} + var wg sync.WaitGroup + results := make(chan error, 2) + for _, token := range tokens { + wg.Go(func() { _, err := attach(h, token, "contended"); results <- err }) + } + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } else { + refused(t, err, "owned by") + } + } + if success != 1 { + t.Fatalf("%d attachments committed", success) + } +} + +func TestAttachmentRechecksRevisionUnderWriteTransaction(t *testing.T) { + h := newHarness(t) + token := attachmentToken(t, h, []string{"a"}, sc(kind, "create")) + 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) + } + }} + svc, err := authority.New(wrapped, h.cfg) + if err != nil { + t.Fatal(err) + } + _, err = svc.AttachResource(h.ctx, authority.AttachmentRequest{Token: token, Store: authority.Workload{Module: "store"}, Kind: kind, ResourceID: "late"}) + refused(t, err, "authorization revision superseded") +} + +type beforeWriteStore struct { + authority.Store + change func() +} + +func (s *beforeWriteStore) Write(ctx context.Context, f func(authority.Tx) error) error { + if s.change != nil { + change := s.change + s.change = nil + change() + } + 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/consent_test.go b/libraries/authority-core/go/authority/consent_test.go index dd5cff1b..4cd1c1c7 100644 --- a/libraries/authority-core/go/authority/consent_test.go +++ b/libraries/authority-core/go/authority/consent_test.go @@ -2,6 +2,7 @@ package authority_test import ( "context" + "reflect" "testing" "github.com/codefly-dev/authority/libraries/authority-core/go/authority" @@ -41,7 +42,7 @@ func TestInstallationConsentBindsReviewedStateAndOperation(t *testing.T) { t.Fatal(err) } retry, err := install(a, "author") - if err != nil || first != retry || steps != 1 { + if err != nil || !reflect.DeepEqual(first, retry) || steps != 1 { t.Fatalf("unchanged retry: %+v, steps=%d, err=%v", retry, steps, err) } _, err = install(a, "viewer") diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go index 13ea1076..91457e4c 100644 --- a/libraries/authority-core/go/authority/lifecycle.go +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -406,6 +406,7 @@ func installationIn(tx Tx, organization, installationID string) (domain.Installa // revoke disables inst: its revision and its organization's authorization // revision both move, and its grants are cleared. func (s *Service) revoke(tx Tx, inst domain.Installation) error { + inst.Owned = nil inst.Status = domain.Revoked inst.Revision++ if err := tx.PutInstallation(inst); err != nil { diff --git a/libraries/authority-core/go/authority/r152_test.go b/libraries/authority-core/go/authority/r152_test.go index 41ac08f6..d4218da4 100644 --- a/libraries/authority-core/go/authority/r152_test.go +++ b/libraries/authority-core/go/authority/r152_test.go @@ -3,6 +3,7 @@ package authority_test import ( "context" "errors" + "reflect" "testing" "github.com/codefly-dev/authority/libraries/authority-core/go/authority" @@ -17,7 +18,7 @@ func TestWithdrawnModuleCannotBeUninstalledByAPerson(t *testing.T) { } before := h.inst(acme, "store") refused(t, h.svc.Uninstall(h.ctx, h.admin(acme), acme, id), "never uninstalled") - if after := h.inst(acme, "store"); after != before { + if after := h.inst(acme, "store"); !reflect.DeepEqual(after, before) { t.Fatalf("refusal mutated installation: %+v", after) } } @@ -42,7 +43,7 @@ func TestMissingCallerCannotSelectHostPower(t *testing.T) { h := newHarness(t) before := h.inst(acme, "store") code(t, h.svc.Uninstall(h.ctx, authority.Person{}, acme, before.ID), domain.Denied) - if h.inst(acme, "store") != before { + if !reflect.DeepEqual(h.inst(acme, "store"), before) { t.Fatal("empty caller retired module") } _, err := h.svc.Install(h.ctx, authority.Person{}, globex, "b", "author", h.admin(globex).ID, h.approval(globex, "b"), nil) @@ -115,7 +116,7 @@ func TestLifecycleStoreFailuresAreReturnedWithoutMutation(t *testing.T) { } else if !errors.Is(err, outage) { t.Fatalf("uninstall hid outage: %v", err) } - if h.inst(acme, "store") != before { + if !reflect.DeepEqual(h.inst(acme, "store"), before) { t.Fatal("outage mutated installation") } _, err = svc.Install(h.ctx, h.admin(globex), globex, "b", "author", h.admin(globex).ID, h.approval(globex, "b"), nil) 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/authority-core/go/decide/decide.go b/libraries/authority-core/go/decide/decide.go index c416667e..e30025c7 100644 --- a/libraries/authority-core/go/decide/decide.go +++ b/libraries/authority-core/go/decide/decide.go @@ -80,17 +80,26 @@ func HeldRoles(p domain.Person, inst domain.Installation, grants []domain.RoleGr // owns. An installation with no principal grants nothing: its scopes would // select every owner's resources. func RoleScopes(solution domain.Module, inst domain.Installation, roles []string) (Scopes, error) { - var held Scopes + var literal, attached Scopes for _, role := range roles { - held = append(held, solution.Roles[role]...) - } - if len(held) == 0 { - return nil, nil + literal = append(literal, solution.Roles[role]...) + for _, sc := range solution.OwnedRoles[role] { + if ids := inst.Owned[sc.GetResourceKind()]; len(ids) > 0 { + attached = append(attached, &basev0.WorkScopeV1{ResourceKind: sc.GetResourceKind(), Actions: slices.Clone(sc.GetActions()), ResourceIds: slices.Clone(ids)}) + } + } } - if inst.PrincipalID == "" { - return nil, domain.Refuse("installation %s has no principal, so its roles grant nothing", inst.ID) + if len(literal) > 0 { + if inst.PrincipalID == "" { + return nil, domain.Refuse("installation %s has no principal, so its roles grant nothing", inst.ID) + } + var err error + literal, err = scope.Owned(literal, inst.PrincipalID) + if err != nil { + return nil, err + } } - return scope.Owned(held, inst.PrincipalID) + return scope.Normalize(append(literal, attached...)), nil } // Issue is asked within held: held when nothing is asked; refused, naming diff --git a/libraries/authority-core/go/decide/installation_owned_test.go b/libraries/authority-core/go/decide/installation_owned_test.go new file mode 100644 index 00000000..fad653ee --- /dev/null +++ b/libraries/authority-core/go/decide/installation_owned_test.go @@ -0,0 +1,78 @@ +package decide_test + +import ( + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/decide" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +func ownedModule() domain.Module { + sc := &basev0.WorkScopeV1{ResourceKind: "collection", Actions: []string{"read"}} + return domain.Module{Name: "solution", Principal: "solution", Kind: domain.KindSolution, Generation: 1, + Bindings: map[string]domain.Binding{"store": {ID: "store", Audience: "store", InvokeScopes: []*basev0.WorkScopeV1{sc}}}, + Roles: map[string][]*basev0.WorkScopeV1{domain.InstallerRole: nil}, + OwnedRoles: map[string][]*basev0.WorkScopeV1{domain.InstallerRole: {sc}}, + } +} + +func TestInstallationOwnedDeclarationRefusals(t *testing.T) { + for _, tc := range []struct { + name, reason string + change func(*domain.Module) + }{ + {"ids", "names resource ids", func(m *domain.Module) { m.OwnedRoles[domain.InstallerRole][0].ResourceIds = []string{"x"} }}, + {"owner", "names resource ids or an owner", func(m *domain.Module) { m.OwnedRoles[domain.InstallerRole][0].ResourceOwnerId = "owner" }}, + {"undeclared", "not declared", func(m *domain.Module) { m.OwnedRoles["missing"] = m.OwnedRoles[domain.InstallerRole] }}, + {"ceiling", "beyond", func(m *domain.Module) { m.Bindings = nil }}, + {"module", "only a solution", func(m *domain.Module) { m.Kind = domain.KindModule }}, + {"empty", "grants nothing", func(m *domain.Module) { m.OwnedRoles = nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + m := ownedModule() + tc.change(&m) + err := decide.ValidateModule(m) + if err == nil || !strings.Contains(err.Error(), tc.reason) { + t.Fatalf("want %q: %v", tc.reason, err) + } + }) + } + if err := decide.ValidateModule(ownedModule()); err != nil { + t.Fatalf("owned-only role: %v", err) + } +} + +func TestInstallationOwnedResolutionNeverEmitsAnEmptyIDGrant(t *testing.T) { + m := ownedModule() + inst := domain.Installation{ID: "installation", PrincipalID: "principal"} + got, err := decide.RoleScopes(m, inst, []string{domain.InstallerRole}) + if err != nil || len(got) != 0 { + t.Fatalf("owns nothing: %v %v", got, err) + } + inst.Owned = map[string][]string{"collection": {"x"}} + got, err = decide.RoleScopes(m, inst, []string{domain.InstallerRole}) + if err != nil || len(got) != 1 || len(got[0].ResourceIds) != 1 || got[0].ResourceIds[0] != "x" || got[0].ResourceOwnerId != "" { + t.Fatalf("exact attached id: %v %v", got, err) + } + got[0].ResourceIds[0] = "mutated" + if inst.Owned["collection"][0] != "x" { + t.Fatal("resolution mutated the installation") + } + m.Roles[domain.InstallerRole] = []*basev0.WorkScopeV1{{ResourceKind: "note", Actions: []string{"read"}}} + got, err = decide.RoleScopes(m, inst, []string{domain.InstallerRole}) + if err != nil { + t.Fatal(err) + } + for _, sc := range got { + if sc.ResourceKind == "note" && sc.ResourceOwnerId != "principal" { + t.Fatal("ordinary roles lost owner selection") + } + } + next := ownedModule() + next.OwnedRoles = nil + if !decide.Narrowed(m, next) { + t.Fatal("withdrawing owned roles must invalidate capabilities") + } +} diff --git a/libraries/authority-core/go/decide/manifest.go b/libraries/authority-core/go/decide/manifest.go index 2cc1cb0a..9e9324cd 100644 --- a/libraries/authority-core/go/decide/manifest.go +++ b/libraries/authority-core/go/decide/manifest.go @@ -45,6 +45,9 @@ func ValidateModule(m domain.Module) error { } } if m.Kind != domain.KindSolution { + if len(m.OwnedRoles) > 0 { + return domain.Refuse("module %q declares installation-owned roles; only a solution declares them", m.Name) + } if len(m.Roles) > 0 { return domain.Refuse("module %q declares roles; a module's access is the organization's roles", m.Name) } @@ -61,7 +64,7 @@ func ValidateModule(m domain.Module) error { } for _, name := range slices.Sorted(maps.Keys(m.Roles)) { scopes := m.Roles[name] - if len(scopes) == 0 { + if len(scopes) == 0 && len(m.OwnedRoles[name]) == 0 { return domain.Refuse("role %q of %s grants nothing", name, m.Name) } if scope.HasOwner(scopes) { @@ -71,6 +74,19 @@ func ValidateModule(m domain.Module) error { return domain.Refuse("role %q of %s grants beyond what %s serves or explicitly consumes through invoke/lookup bindings", name, m.Name, m.Name) } } + for _, name := range slices.Sorted(maps.Keys(m.OwnedRoles)) { + if _, ok := m.Roles[name]; !ok { + return domain.Refuse("installation-owned role %q of %s is not declared", name, m.Name) + } + for _, sc := range m.OwnedRoles[name] { + if len(sc.GetResourceIds()) > 0 || sc.GetResourceOwnerId() != "" { + return domain.Refuse("installation-owned role %q names resource ids or an owner; its installation supplies the ids", name) + } + } + if !scope.Attenuates(m.OwnedRoles[name], grantable) { + return domain.Refuse("installation-owned role %q grants beyond what %s serves or explicitly consumes", name, m.Name) + } + } return nil } @@ -91,6 +107,11 @@ func Narrowed(previous, next domain.Module) bool { if previous.Kind != domain.KindSolution { return false } + for name, scopes := range previous.OwnedRoles { + if !scope.Attenuates(scopes, next.OwnedRoles[name]) { + return true + } + } for name, scopes := range previous.Roles { if !scope.Attenuates(scopes, next.Roles[name]) { return true diff --git a/libraries/authority-core/go/domain/domain.go b/libraries/authority-core/go/domain/domain.go index 6278ba60..b7fb1dca 100644 --- a/libraries/authority-core/go/domain/domain.go +++ b/libraries/authority-core/go/domain/domain.go @@ -63,6 +63,8 @@ type Module struct { Bindings map[string]Binding // Roles are a solution's roles: levels on the data its principal owns. Roles map[string][]*basev0.WorkScopeV1 + // OwnedRoles resolve only to the exact ids attached to this installation. + OwnedRoles map[string][]*basev0.WorkScopeV1 // OwnerAware: the module compares a resource's owner with an // owner-selected scope, so it may receive one. OwnerAware bool @@ -108,6 +110,8 @@ type Installation struct { InstalledBy string // Consent is the last reviewed operation; a later revision invalidates its receipt. Consent ConsentReceipt + // Owned is the bounded set of attached resource ids, by kind. + Owned map[string][]string } // RoleGrant is one role on one installation held by exactly one person or team. diff --git a/libraries/authority-core/go/memstore/memstore.go b/libraries/authority-core/go/memstore/memstore.go index bf263887..f6fb428b 100644 --- a/libraries/authority-core/go/memstore/memstore.go +++ b/libraries/authority-core/go/memstore/memstore.go @@ -253,13 +253,13 @@ func (t tx) Installation(id string) (domain.Installation, error) { if !ok { return i, missing("installation", id) } - return i, nil + return cloneInstallation(i), nil } func (t tx) InstallationByPrincipal(principal string) (domain.Installation, error) { for _, i := range t.s.installations { if principal != "" && i.PrincipalID == principal { - return i, nil + return cloneInstallation(i), nil } } return domain.Installation{}, missing("principal", principal) @@ -269,7 +269,7 @@ func (t tx) Installations(org string) ([]domain.Installation, error) { var out []domain.Installation for _, i := range t.s.installations { if org == "" || i.Organization == org { - out = append(out, i) + out = append(out, cloneInstallation(i)) } } sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) @@ -278,7 +278,7 @@ func (t tx) Installations(org string) ([]domain.Installation, error) { func (t tx) PutInstallation(i domain.Installation) error { t.mustWrite() - t.s.installations[i.ID] = i + t.s.installations[i.ID] = cloneInstallation(i) return nil } @@ -397,5 +397,19 @@ func cloneModule(m domain.Module) domain.Module { } m.Roles = roles } + owned := make(map[string][]*basev0.WorkScopeV1, len(m.OwnedRoles)) + for role, scopes := range m.OwnedRoles { + owned[role] = cloneScopes(scopes) + } + m.OwnedRoles = owned return m } + +func cloneInstallation(i domain.Installation) domain.Installation { + owned := make(map[string][]string, len(i.Owned)) + for kind, ids := range i.Owned { + owned[kind] = slices.Clone(ids) + } + i.Owned = owned + return i +} diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 7275d76e..29a75683 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, 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/conformance.go b/libraries/conformance/go/conformance.go index 050f1ce8..6c397e18 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 @@ -618,6 +622,9 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt } c.installationChain(t, ctx, f, *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..a0ba98e6 --- /dev/null +++ b/libraries/conformance/go/installation_resources.go @@ -0,0 +1,279 @@ +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. Each +// solution serves its own kind, ".note": a kind has one serving module. +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. + // ResourceX and ResourceY exist and no installation owns them: Owner, as + // A's installer, has a personal organization grant to administer X only. + ResourceA, ResourceA2, ResourceB, ResourceX, ResourceY string +} + +// OwnedInstallation is one installed solution of the case. +type OwnedInstallation struct { + Solution, Credential, Installation string +} + +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 || 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, 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", "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 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, 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", f.Kind+":write")}}); 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}) + // Ordinary installer scopes retain main's resource-owner selection. + // Exact installation-owned viewer scopes are already bounded by their ids. + if person == owner { + principal := "" + rows := field(msg(root, "context"), "authority_scopes").List() + if rows.Len() > 0 { + principal = str(rows.Get(0).Message(), "resource_owner_id") + } + selected := make([]any, 0, len(scopes)) + for _, ask := range scopes { + fields := map[string]any{} + for key, value := range ask.(map[string]any) { + fields[key] = value + } + fields["resource_owner_id"] = principal + selected = append(selected, fields) + } + scopes = selected + } + 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 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") + // 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 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) + reason := "owned by" + if index != 0 { + reason = "AuthorizeTransfer" + } + refused(t, err, codes.PermissionDenied, reason) + } + } + }) + 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 no role") + }) + 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") + } + }) + 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(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") + } + 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, "principal_id"); got != principal { + t.Fatalf("transfer owner %s, want %s", got, principal) + } + 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/module.codefly.yaml b/modules/authority/module.codefly.yaml index 75c9e1b0..7fcb0e6b 100644 --- a/modules/authority/module.codefly.yaml +++ b/modules/authority/module.codefly.yaml @@ -8,7 +8,7 @@ interface: - service: local endpoint: grpc implements: - - codefly.dev/authority@0.3.0 + - codefly.dev/authority@0.3.1 - codefly.dev/composition@0.2.0 - codefly.dev/installation@0.2.0 - codefly.dev/session@0.2.0 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..102140fb --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/attach_test.go @@ -0,0 +1,127 @@ +package issuer_test + +import ( + "context" + "errors" + "github.com/codefly-dev/authority/libraries/authority-core/go/decide" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "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" +) + +// 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{Name: "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) + } + } +} + +// 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{Name: "proposals", Principal: "proposals", Kind: "solution", OwnerAware: true, 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.InstallAsHost(context.Background(), "acme", "proposals", "viewer", "alex") + if err != nil { + t.Fatal(err) + } + if err := k.State.GrantRole(domain.RoleGrant{InstallationID: inst.ID, Role: "viewer", TeamID: "finance"}); err != nil { + t.Fatal(err) + } + if err := k.State.GrantRole(domain.RoleGrant{InstallationID: inst.ID, Role: state.InstallerRole, PersonID: "alex"}); 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) + } + m, err := k.State.ModuleByPrincipal("proposals") + if err != nil { + t.Fatal(err) + } + inst, _ := k.State.Installation(id) + held, err := decide.RoleScopes(m, inst, []string{"viewer"}) + 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 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{ + As: &authorityv1.IssueRequest_Installation{Installation: &authorityv1.InstallationRef{InstallationId: id}}, + Actor: &authorityv1.WorkloadCredential{Credential: k.Credential(t, "proposals")}, Audience: "documents", OperationBindingId: "documents"}) + if err != nil { + t.Fatal(err) + } + if agent.GetContext().GetOwnerPrincipalKind() != "agent" { + t.Fatalf("owner kind %q", agent.GetContext().GetOwnerPrincipalKind()) + } + _, 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) + if len(inst.Owned) != 0 { + t.Fatal("refused attachment changed ownership") + } +} 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 new file mode 100644 index 00000000..f08b6f1a --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/issuer/solution_roles.go @@ -0,0 +1,26 @@ +package issuer + +import ( + "context" + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" +) + +// AttachResource delegates ownership decisions to the shared authority core. +func (i *Issuer) AttachResource(ctx context.Context, token, credential, kind, id string) (string, error) { + w, err := i.workload(ctx, credential, "an attachment is asked by the module serving the kind") + if err != nil { + return "", err + } + out, err := i.svc.AttachResource(ctx, authority.AttachmentRequest{Token: token, Store: w, Kind: kind, ResourceID: id}) + return out, classify(err) +} + +// DetachResource delegates deletion to the shared authority core. +func (i *Issuer) DetachResource(ctx context.Context, credential, org, kind, id string) (string, error) { + w, err := i.workload(ctx, credential, "a detachment is asked by the module serving the kind") + if err != nil { + return "", err + } + out, err := i.svc.DetachResource(ctx, w, org, kind, id) + return out, classify(err) +} 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..a6366201 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/installation_owned_test.go @@ -0,0 +1,41 @@ +package state_test + +import ( + "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) + } +} 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 4de020c1..042efef9 100644 --- a/modules/authority/services/local/code/pkg/business/state/permissions.go +++ b/modules/authority/services/local/code/pkg/business/state/permissions.go @@ -134,7 +134,9 @@ func (s *State) PermissionReport() []string { roles := map[string][]*basev0.WorkScopeV1{} if m.Kind == domain.KindSolution { 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 _, role := range orgRoles { 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 46de3ee5..05887187 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -279,7 +279,7 @@ func Bare(t *testing.T) *Kit { // manifests from their own workloads. // robin and assistant are declared for the artifact tests, which compose // them themselves. - for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store"} { + for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store", "conformance-owner-a", "conformance-owner-b", "conformance-store"} { 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 114b93c5..24747191 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,244 @@ 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 "" +} + +// 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 "" +} + // AuthorizeTransferRequest names one existing resource and where it moves. type AuthorizeTransferRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1239,7 +1477,7 @@ type AuthorizeTransferRequest struct { func (x *AuthorizeTransferRequest) Reset() { *x = AuthorizeTransferRequest{} - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1251,7 +1489,7 @@ func (x *AuthorizeTransferRequest) String() string { func (*AuthorizeTransferRequest) ProtoMessage() {} func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[18] + mi := &file_host_authority_v1_authority_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1264,7 +1502,7 @@ func (x *AuthorizeTransferRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferRequest.ProtoReflect.Descriptor instead. func (*AuthorizeTransferRequest) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{18} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{22} } func (x *AuthorizeTransferRequest) GetToken() string { @@ -1322,7 +1560,7 @@ type AuthorizeTransferResponse struct { func (x *AuthorizeTransferResponse) Reset() { *x = AuthorizeTransferResponse{} - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1334,7 +1572,7 @@ func (x *AuthorizeTransferResponse) String() string { func (*AuthorizeTransferResponse) ProtoMessage() {} func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_authority_v1_authority_proto_msgTypes[19] + mi := &file_host_authority_v1_authority_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1347,7 +1585,7 @@ func (x *AuthorizeTransferResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthorizeTransferResponse.ProtoReflect.Descriptor instead. func (*AuthorizeTransferResponse) Descriptor() ([]byte, []int) { - return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{19} + return file_host_authority_v1_authority_proto_rawDescGZIP(), []int{23} } func (x *AuthorizeTransferResponse) GetPrincipalId() string { @@ -1384,7 +1622,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[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1396,7 +1634,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[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1409,7 +1647,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{24} } func (x *ResourceGrant) GetMatch() isResourceGrant_Match { @@ -1578,7 +1816,23 @@ 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\"\xff\x01\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\"\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\"\xff\x01\n" + "\x18AuthorizeTransferRequest\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12;\n" + "\x05actor\x18\x02 \x01(\v2%.host.authority.v1.WorkloadCredentialR\x05actor\x12#\n" + @@ -1596,12 +1850,14 @@ const file_host_authority_v1_authority_proto_rawDesc = "" + "\x06prefix\x18\x03 \x01(\tH\x00R\x06prefix\x12\x1a\n" + "\apattern\x18\x04 \x01(\tH\x00R\apattern\x12*\n" + "\x11resource_owner_id\x18\x05 \x01(\tR\x0fresourceOwnerIdB\a\n" + - "\x05match2\x9c\x06\n" + + "\x05match2\xea\a\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\x12n\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\x12e\n" + + "\x0eDetachResource\x12(.host.authority.v1.DetachResourceRequest\x1a).host.authority.v1.DetachResourceResponse\x12n\n" + "\x11AuthorizeTransfer\x12+.host.authority.v1.AuthorizeTransferRequest\x1a,.host.authority.v1.AuthorizeTransferResponse\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" + @@ -1619,7 +1875,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, 25) var file_host_authority_v1_authority_proto_goTypes = []any{ (*IssueRequest)(nil), // 0: host.authority.v1.IssueRequest (*DelegationRef)(nil), // 1: host.authority.v1.DelegationRef @@ -1639,49 +1895,59 @@ 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 - (*AuthorizeTransferRequest)(nil), // 18: host.authority.v1.AuthorizeTransferRequest - (*AuthorizeTransferResponse)(nil), // 19: host.authority.v1.AuthorizeTransferResponse - (*ResourceGrant)(nil), // 20: host.authority.v1.ResourceGrant - (*WorkScopeV1)(nil), // 21: host.authority.v1.WorkScopeV1 - (*WorkContextV1)(nil), // 22: host.authority.v1.WorkContextV1 + (*AttachResourceRequest)(nil), // 18: host.authority.v1.AttachResourceRequest + (*AttachResourceResponse)(nil), // 19: host.authority.v1.AttachResourceResponse + (*DetachResourceRequest)(nil), // 20: host.authority.v1.DetachResourceRequest + (*DetachResourceResponse)(nil), // 21: host.authority.v1.DetachResourceResponse + (*AuthorizeTransferRequest)(nil), // 22: host.authority.v1.AuthorizeTransferRequest + (*AuthorizeTransferResponse)(nil), // 23: host.authority.v1.AuthorizeTransferResponse + (*ResourceGrant)(nil), // 24: host.authority.v1.ResourceGrant + (*WorkScopeV1)(nil), // 25: host.authority.v1.WorkScopeV1 + (*WorkContextV1)(nil), // 26: 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 + 25, // 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 + 25, // 6: host.authority.v1.DelegateRequest.scopes:type_name -> host.authority.v1.WorkScopeV1 + 25, // 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 + 26, // 10: host.authority.v1.IssueResponse.context:type_name -> host.authority.v1.WorkContextV1 + 26, // 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 - 8, // 14: host.authority.v1.AuthorizeTransferRequest.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.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest - 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.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse - 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 + 24, // 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 + 8, // 15: host.authority.v1.DetachResourceRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 8, // 16: host.authority.v1.AuthorizeTransferRequest.actor:type_name -> host.authority.v1.WorkloadCredential + 0, // 17: host.authority.v1.AuthorityService.Issue:input_type -> host.authority.v1.IssueRequest + 11, // 18: host.authority.v1.AuthorityService.Check:input_type -> host.authority.v1.CheckRequest + 13, // 19: host.authority.v1.AuthorityService.Keys:input_type -> host.authority.v1.KeysRequest + 16, // 20: host.authority.v1.AuthorityService.ListResources:input_type -> host.authority.v1.ListResourcesRequest + 18, // 21: host.authority.v1.AuthorityService.AttachResource:input_type -> host.authority.v1.AttachResourceRequest + 20, // 22: host.authority.v1.AuthorityService.DetachResource:input_type -> host.authority.v1.DetachResourceRequest + 22, // 23: host.authority.v1.AuthorityService.AuthorizeTransfer:input_type -> host.authority.v1.AuthorizeTransferRequest + 2, // 24: host.authority.v1.AuthorityService.Delegate:input_type -> host.authority.v1.DelegateRequest + 5, // 25: host.authority.v1.AuthorityService.RevokeDelegation:input_type -> host.authority.v1.RevokeDelegationRequest + 4, // 26: host.authority.v1.AuthorityService.GetDelegation:input_type -> host.authority.v1.GetDelegationRequest + 10, // 27: host.authority.v1.AuthorityService.Issue:output_type -> host.authority.v1.IssueResponse + 12, // 28: host.authority.v1.AuthorityService.Check:output_type -> host.authority.v1.CheckResponse + 14, // 29: host.authority.v1.AuthorityService.Keys:output_type -> host.authority.v1.KeysResponse + 17, // 30: host.authority.v1.AuthorityService.ListResources:output_type -> host.authority.v1.ListResourcesResponse + 19, // 31: host.authority.v1.AuthorityService.AttachResource:output_type -> host.authority.v1.AttachResourceResponse + 21, // 32: host.authority.v1.AuthorityService.DetachResource:output_type -> host.authority.v1.DetachResourceResponse + 23, // 33: host.authority.v1.AuthorityService.AuthorizeTransfer:output_type -> host.authority.v1.AuthorizeTransferResponse + 3, // 34: host.authority.v1.AuthorityService.Delegate:output_type -> host.authority.v1.Delegation + 6, // 35: host.authority.v1.AuthorityService.RevokeDelegation:output_type -> host.authority.v1.RevokeDelegationResponse + 3, // 36: host.authority.v1.AuthorityService.GetDelegation:output_type -> host.authority.v1.Delegation + 27, // [27:37] is the sub-list for method output_type + 17, // [17:27] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_host_authority_v1_authority_proto_init() } @@ -1697,7 +1963,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[24].OneofWrappers = []any{ (*ResourceGrant_All)(nil), (*ResourceGrant_Id)(nil), (*ResourceGrant_Prefix)(nil), @@ -1709,7 +1975,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: 25, 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 e548980e..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 @@ -30,6 +30,8 @@ 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_DetachResource_FullMethodName = "/host.authority.v1.AuthorityService/DetachResource" AuthorityService_AuthorizeTransfer_FullMethodName = "/host.authority.v1.AuthorityService/AuthorizeTransfer" AuthorityService_Delegate_FullMethodName = "/host.authority.v1.AuthorityService/Delegate" AuthorityService_RevokeDelegation_FullMethodName = "/host.authority.v1.AuthorityService/RevokeDelegation" @@ -58,6 +60,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 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 "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 + // 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. 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) // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -68,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. @@ -131,6 +169,26 @@ 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) 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) AuthorizeTransfer(ctx context.Context, in *AuthorizeTransferRequest, opts ...grpc.CallOption) (*AuthorizeTransferResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(AuthorizeTransferResponse) @@ -193,6 +251,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 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 "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 + // 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. 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) // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -203,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. @@ -238,6 +332,12 @@ 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) DetachResource(context.Context, *DetachResourceRequest) (*DetachResourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DetachResource not implemented") +} func (UnimplementedAuthorityServiceServer) AuthorizeTransfer(context.Context, *AuthorizeTransferRequest) (*AuthorizeTransferResponse, error) { return nil, status.Error(codes.Unimplemented, "method AuthorizeTransfer not implemented") } @@ -343,6 +443,42 @@ 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_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_AuthorizeTransfer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(AuthorizeTransferRequest) if err := dec(in); err != nil { @@ -438,6 +574,14 @@ var AuthorityService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListResources", Handler: _AuthorityService_ListResources_Handler, }, + { + MethodName: "AttachResource", + Handler: _AuthorityService_AttachResource_Handler, + }, + { + MethodName: "DetachResource", + Handler: _AuthorityService_DetachResource_Handler, + }, { MethodName: "AuthorizeTransfer", Handler: _AuthorityService_AuthorizeTransfer_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 f04ce2fd..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 @@ -48,6 +48,12 @@ 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" + // AuthorityServiceDetachResourceProcedure is the fully-qualified name of the AuthorityService's + // DetachResource RPC. + AuthorityServiceDetachResourceProcedure = "/host.authority.v1.AuthorityService/DetachResource" // AuthorityServiceAuthorizeTransferProcedure is the fully-qualified name of the AuthorityService's // AuthorizeTransfer RPC. AuthorityServiceAuthorizeTransferProcedure = "/host.authority.v1.AuthorityService/AuthorizeTransfer" @@ -80,6 +86,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 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 "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 + // 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. 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) // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -90,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. @@ -140,6 +182,18 @@ 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...), + ), + detachResource: connect.NewClient[v1.DetachResourceRequest, v1.DetachResourceResponse]( + httpClient, + baseURL+AuthorityServiceDetachResourceProcedure, + connect.WithSchema(authorityServiceMethods.ByName("DetachResource")), + connect.WithClientOptions(opts...), + ), authorizeTransfer: connect.NewClient[v1.AuthorizeTransferRequest, v1.AuthorizeTransferResponse]( httpClient, baseURL+AuthorityServiceAuthorizeTransferProcedure, @@ -173,6 +227,8 @@ 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] + detachResource *connect.Client[v1.DetachResourceRequest, v1.DetachResourceResponse] authorizeTransfer *connect.Client[v1.AuthorizeTransferRequest, v1.AuthorizeTransferResponse] delegate *connect.Client[v1.DelegateRequest, v1.Delegation] revokeDelegation *connect.Client[v1.RevokeDelegationRequest, v1.RevokeDelegationResponse] @@ -199,6 +255,16 @@ 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) +} + +// 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) +} + // AuthorizeTransfer calls host.authority.v1.AuthorityService.AuthorizeTransfer. func (c *authorityServiceClient) AuthorizeTransfer(ctx context.Context, req *connect.Request[v1.AuthorizeTransferRequest]) (*connect.Response[v1.AuthorizeTransferResponse], error) { return c.authorizeTransfer.CallUnary(ctx, req) @@ -237,6 +303,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 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 "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 + // 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. 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) // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -247,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. @@ -293,6 +395,18 @@ 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...), + ) + authorityServiceDetachResourceHandler := connect.NewUnaryHandler( + AuthorityServiceDetachResourceProcedure, + svc.DetachResource, + connect.WithSchema(authorityServiceMethods.ByName("DetachResource")), + connect.WithHandlerOptions(opts...), + ) authorityServiceAuthorizeTransferHandler := connect.NewUnaryHandler( AuthorityServiceAuthorizeTransferProcedure, svc.AuthorizeTransfer, @@ -327,6 +441,10 @@ func NewAuthorityServiceHandler(svc AuthorityServiceHandler, opts ...connect.Han authorityServiceKeysHandler.ServeHTTP(w, r) case AuthorityServiceListResourcesProcedure: authorityServiceListResourcesHandler.ServeHTTP(w, r) + case AuthorityServiceAttachResourceProcedure: + authorityServiceAttachResourceHandler.ServeHTTP(w, r) + case AuthorityServiceDetachResourceProcedure: + authorityServiceDetachResourceHandler.ServeHTTP(w, r) case AuthorityServiceAuthorizeTransferProcedure: authorityServiceAuthorizeTransferHandler.ServeHTTP(w, r) case AuthorityServiceDelegateProcedure: @@ -360,6 +478,14 @@ 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) 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) AuthorizeTransfer(context.Context, *connect.Request[v1.AuthorizeTransferRequest]) (*connect.Response[v1.AuthorizeTransferResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("host.authority.v1.AuthorityService.AuthorizeTransfer 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 f1416503..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 @@ -524,11 +524,20 @@ 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 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 + // 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() { @@ -575,6 +584,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"` @@ -816,10 +832,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" + @@ -874,16 +891,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 3082f002..12179578 100644 --- a/modules/authority/services/local/code/plugins/authority/plugin.go +++ b/modules/authority/services/local/code/plugins/authority/plugin.go @@ -189,6 +189,25 @@ 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 +} + +// 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 +} + // AuthorizeTransfer decides whether an existing resource joins a solution // principal; the store records the answer. func (p *Plugin) AuthorizeTransfer(ctx context.Context, req *authorityv1.AuthorizeTransferRequest) (*authorityv1.AuthorizeTransferResponse, error) { diff --git a/modules/authority/services/local/code/plugins/composition/plugin.go b/modules/authority/services/local/code/plugins/composition/plugin.go index c62aced2..d0765ff5 100644 --- a/modules/authority/services/local/code/plugins/composition/plugin.go +++ b/modules/authority/services/local/code/plugins/composition/plugin.go @@ -93,6 +93,12 @@ func (p *Plugin) ApplyManifest(ctx context.Context, req *compositionv1.ApplyMani if m, err = ceiled(m, issuer.FromWire(doc.GetScopeCeilings())); err != nil { return nil, err } + m.OwnedRoles = map[string][]*basev0.WorkScopeV1{} + for _, role := range doc.GetRoles() { + if len(role.GetInstallationOwnedScopes()) > 0 { + m.OwnedRoles[role.GetName()] = issuer.FromWire(role.GetInstallationOwnedScopes()) + } + } if m.Roles, err = rolesOf(doc); err != nil { return nil, err } 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 bd2d2a81..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 @@ -27,6 +27,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) { @@ -124,7 +125,54 @@ 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{} + for _, solution := range []string{"conformance-owner-a", "conformance-owner-b"} { + note := testkit.Scope(solution+".note", "read") + m := state.Manifest{Name: solution, Principal: solution, Kind: "solution", OwnerAware: true, 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.InstallAsHost(context.Background(), "acme", solution, "viewer", "alex") + if err != nil { + t.Fatal(err) + } + owned[solution] = inst.ID + if err := k.State.GrantRole(domain.RoleGrant{InstallationID: inst.ID, Role: state.InstallerRole, PersonID: "alex"}); err != nil { + t.Fatal(err) + } + } + if err := k.State.GrantRole(domain.RoleGrant{InstallationID: owned["conformance-owner-a"], Role: "viewer", TeamID: "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", 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"}, 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 0cf2ccb3..7b716dcc 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,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 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 "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 + // 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. 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); // AuthorizeTransfer decides whether an existing resource may join an // installed solution's principal. The caller is the kind's one serving // module, which attests the resource exists and who owns it now @@ -43,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. @@ -257,6 +293,42 @@ 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; +} + +// 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; +} + // AuthorizeTransferRequest names one existing resource and where it moves. message AuthorizeTransferRequest { // The capability of the person asking, addressed to the caller. 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 06bd518f..a834fd4c 100644 --- a/modules/authority/services/local/proto/host/composition/v1/composition.proto +++ b/modules/authority/services/local/proto/host/composition/v1/composition.proto @@ -126,6 +126,15 @@ 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 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 + // 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/tools/noproxygate/generated.json b/tools/noproxygate/generated.json index 455dbdd8..cd992d5e 100644 --- a/tools/noproxygate/generated.json +++ b/tools/noproxygate/generated.json @@ -3,10 +3,10 @@ "libraries/authority-client/go/gen/api_grpc.pb.go": "71617cc6107e9332fa88dc1889892378cf36e0887a5d05b87e7af8089a158f5a", "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": "ed072d97f03494f3c8e21d4c9f8da2899f450094bff0fef1bc7280531749fd48", - "libraries/authority-client/go/gen/host/authority/v1/authority_grpc.pb.go": "ae0bc78f94aec0fcc130d082be6050287599f758bc841f727e43600c0424328f", + "libraries/authority-client/go/gen/host/authority/v1/authority.pb.go": "e9e35ce1a1b3e5d627b33b44653ae1d7913d684dfe12d512b2d8fd6526dd294a", + "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": "0a2eedf2b7cec24d9006835e5d2718be3c139ad6d9f0e8da9d925310613cda1c", + "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", @@ -39,12 +39,12 @@ "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact.pb.go": "13863e4c49e9b038ea946ac6c2a373dc222f5b1fb8ddc437109c9bd2afa7bb3f", "modules/authority/services/local/code/pkg/gen/host/artifact/v1/artifact_grpc.pb.go": "d30b417db566534c3cb3e8a0ce4410ba0660698a8dcbeb83b36ac2fb726810e0", "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": "f54746ced105e6051e616be6ce2bb5025a84491f838040fe47ca5aacb73560b6", + "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": "ae0bc78f94aec0fcc130d082be6050287599f758bc841f727e43600c0424328f", - "modules/authority/services/local/code/pkg/gen/host/authority/v1/authorityv1connect/authority.connect.go": "bf242c346e442a15c7a8ff3195601f39c5d3d239a1cba040486c27c24fcef132", + "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": "42c1a05e5767a0378c3826a15cd2d625b044d0639c1b37f111fa27d9e21e9db5", + "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",