diff --git a/docs/work-context.md b/docs/work-context.md index af11f299..c80b0453 100644 --- a/docs/work-context.md +++ b/docs/work-context.md @@ -87,10 +87,17 @@ child born superseded whenever a revocation landed in between. A `Grant` keeps the revision the decision was made at, because bumping past it is exactly what revokes an unspent grant. -`WorkScopeV1` is `(resource_kind, actions, resource_ids)`. Attenuation -(`workcontext.ScopeContained`) lets a child narrow a wildcard parent to -explicit ids and never the reverse, and never name an action the parent does -not hold. +`WorkScopeV1` is `(resource_kind, actions, resource_ids, resource_owner_id)`. +Attenuation (`workcontext.ScopeContained`) lets a child narrow a wildcard +parent to explicit ids and never the reverse, and never name an action the +parent does not hold. `resource_owner_id` selects the resources one principal +owns (an installed solution's principal), and narrows the same way: a child +may add an owner to a row that names none, never drop or change one. With ids, +a resource is covered only when both match. A reader asks with the resource's +owner; an ask naming no owner is never covered by an owner-selected row, so an +owner-blind check fails closed rather than reading the scope as the whole +kind. Runnable operations never declare an owner: the authority selects it for +an installation. Every exchange deep-copies the parent's claims. Sharing hop or scope messages would let a mutation of the new capability rewrite what the parent is diff --git a/generated/go/codefly/base/v0/work_context.pb.go b/generated/go/codefly/base/v0/work_context.pb.go index 72d08f04..abeedf00 100644 --- a/generated/go/codefly/base/v0/work_context.pb.go +++ b/generated/go/codefly/base/v0/work_context.pb.go @@ -528,7 +528,10 @@ func (x *WorkOperationBindingV1) GetIncarnation() uint64 { // WorkScopeV1 is a structured, product-neutral capability scope. Empty // resource_ids means every resource of resource_kind; a child may narrow that -// wildcard to explicit IDs but may never widen an explicit parent set. +// wildcard to explicit IDs but may never widen an explicit parent set. A +// non-empty resource_owner_id narrows the scope to the resources that +// principal owns; a child may add an owner to a scope that names none, but +// may never drop or change one. type WorkScopeV1 struct { state protoimpl.MessageState `protogen:"open.v1"` // resource_kind is the class of resource the scope grants against. @@ -537,9 +540,15 @@ type WorkScopeV1 struct { Actions []string `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` // resource_ids narrows the scope to specific resources. Empty means every // resource of resource_kind. - ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ResourceIds []string `protobuf:"bytes,3,rep,name=resource_ids,json=resourceIds,proto3" json:"resource_ids,omitempty"` + // resource_owner_id narrows the scope to the resources this principal owns + // (an installed solution's principal). Empty means the scope selects no + // owner. With resource_ids, a resource is covered only when both match. A + // reader that cannot compare a resource's owner must not treat an + // owner-selected scope as covering the whole kind. + ResourceOwnerId string `protobuf:"bytes,4,opt,name=resource_owner_id,json=resourceOwnerId,proto3" json:"resource_owner_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkScopeV1) Reset() { @@ -593,6 +602,13 @@ func (x *WorkScopeV1) GetResourceIds() []string { return nil } +func (x *WorkScopeV1) GetResourceOwnerId() string { + if x != nil { + return x.ResourceOwnerId + } + return "" +} + // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's // effective scopes and must monotonically attenuate the previous hop. type WorkActorV1 struct { @@ -1022,12 +1038,13 @@ const file_codefly_base_v0_work_context_proto_rawDesc = "" + "binding_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\tbindingId\x12#\n" + "\brevision\x18\x02 \x01(\x04B\a\xbaH\x042\x02(\x01R\brevision\x12)\n" + - "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"{\n" + + "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"\xc0\x01\n" + "\vWorkScopeV1\x12/\n" + "\rresource_kind\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\fresourceKind\x12\x18\n" + "\aactions\x18\x02 \x03(\tR\aactions\x12!\n" + - "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\"\xce\x03\n" + + "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\x12C\n" + + "\x11resource_owner_id\x18\x04 \x01(\tB\x17\xbaH\x14r\x12\x18\x80\x042\r^[^\\s\\x00*]*$R\x0fresourceOwnerId\"\xce\x03\n" + "\vWorkActorV1\x12-\n" + "\fprincipal_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\vprincipalId\x121\n" + diff --git a/generated/python/codefly-cli/codefly_cli/codefly/base/v0/work_context_pb2.py b/generated/python/codefly-cli/codefly_cli/codefly/base/v0/work_context_pb2.py index 43e06333..a72793cc 100644 --- a/generated/python/codefly-cli/codefly_cli/codefly/base/v0/work_context_pb2.py +++ b/generated/python/codefly-cli/codefly_cli/codefly/base/v0/work_context_pb2.py @@ -25,7 +25,7 @@ from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"codefly/base/v0/work_context.proto\x12\x0f\x63odefly.base.v0\x1a\x1b\x62uf/validate/validate.proto\"\xa4\x0c\n\rWorkContextV1\x12\x30\n\x03typ\x18\x01 \x01(\tB\x1e\xbaH\x1br\x19\n\x17\x63odefly.work-context/v1R\x03typ\x12,\n\talgorithm\x18\x02 \x01(\tB\x0e\xbaH\x0br\t\n\x07\x45\x64\x32\x35\x35\x31\x39R\talgorithm\x12!\n\x06key_id\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\x05keyId\x12\"\n\x06issuer\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x06issuer\x12&\n\x08\x61udience\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08\x61udience\x12&\n\x0fnot_before_unix\x18\x06 \x01(\x03R\rnotBeforeUnix\x12$\n\x0eissued_at_unix\x18\x07 \x01(\x03R\x0cissuedAtUnix\x12&\n\x0f\x65xpires_at_unix\x18\x08 \x01(\x03R\rexpiresAtUnix\x12 \n\x05nonce\x18\t \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x05nonce\x12\x35\n\x16\x61uthorization_revision\x18\n \x01(\x04R\x15\x61uthorizationRevision\x12\x42\n\rreplay_policy\x18\x0b \x01(\tB\x1d\xbaH\x1ar\x18R\nidempotentR\nsingle-useR\x0creplayPolicy\x12\'\n\ttenant_id\x18\x0c \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08tenantId\x12\x38\n\x12owner_principal_id\x18\r \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x10ownerPrincipalId\x12#\n\x07task_id\x18\x0e \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x06taskId\x12)\n\nsession_id\x18\x0f \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\tsessionId\x12;\n\x11parent_session_id\x18\x10 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x00R\x0fparentSessionId\x88\x01\x01\x12G\n\x10\x61uthority_scopes\x18\x11 \x03(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1R\x0f\x61uthorityScopes\x12=\n\x0b\x61\x63tor_chain\x18\x12 \x03(\x0b\x32\x1c.codefly.base.v0.WorkActorV1R\nactorChain\x12\x30\n\x14\x61ttribution_team_ids\x18\x13 \x03(\tR\x12\x61ttributionTeamIds\x12\x32\n\x0cworkspace_id\x18\x14 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x01R\x0bworkspaceId\x88\x01\x01\x12.\n\nproject_id\x18\x15 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x02R\tprojectId\x88\x01\x01\x12\x41\n\x14owner_principal_kind\x18\x16 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01H\x03R\x12ownerPrincipalKind\x88\x01\x01\x12\x35\n\x0eowner_agent_id\x18\x17 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x04R\x0cownerAgentId\x88\x01\x01\x12\x38\n\x0forganization_id\x18\x19 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x05R\x0eorganizationId\x88\x01\x01\x12\x41\n\tgrant_hop\x18\x18 \x01(\x0b\x32\x1f.codefly.base.v0.WorkGrantHopV1H\x06R\x08grantHop\x88\x01\x01\x12\x37\n\x04seal\x18\x1a \x01(\x0b\x32\x1b.codefly.base.v0.WorkSealV1B\x06\xbaH\x03\xc8\x01\x01R\x04seal\x12Y\n\x11operation_binding\x18\x1b \x01(\x0b\x32\'.codefly.base.v0.WorkOperationBindingV1H\x07R\x10operationBinding\x88\x01\x01\x42\x14\n\x12_parent_session_idB\x0f\n\r_workspace_idB\r\n\x0b_project_idB\x17\n\x15_owner_principal_kindB\x11\n\x0f_owner_agent_idB\x12\n\x10_organization_idB\x0c\n\n_grant_hopB\x14\n\x12_operation_binding\"\xde\x01\n\nWorkSealV1\x12\x30\n\x0fprincipal_epoch\x18\x01 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x0eprincipalEpoch\x12\x33\n\x0finstallation_id\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0einstallationId\x12<\n\x15installation_revision\x18\x03 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x14installationRevisionJ\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06R\x11\x62uild_incarnationR\x0cimage_digest\"\x93\x01\n\x16WorkOperationBindingV1\x12)\n\nbinding_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\tbindingId\x12#\n\x08revision\x18\x02 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x08revision\x12)\n\x0bincarnation\x18\x03 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x0bincarnation\"{\n\x0bWorkScopeV1\x12/\n\rresource_kind\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\x0cresourceKind\x12\x18\n\x07\x61\x63tions\x18\x02 \x03(\tR\x07\x61\x63tions\x12!\n\x0cresource_ids\x18\x03 \x03(\tR\x0bresourceIds\"\xce\x03\n\x0bWorkActorV1\x12-\n\x0cprincipal_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0bprincipalId\x12\x31\n\x0eprincipal_kind\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\rprincipalKind\x12/\n\rdelegation_id\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0c\x64\x65legationId\x12\x43\n\x0egranted_scopes\x18\x04 \x03(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1R\rgrantedScopes\x12*\n\x08\x61gent_id\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x00R\x07\x61gentId\x88\x01\x01\x12\x38\n\x0forganization_id\x18\x06 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x01R\x0eorganizationId\x88\x01\x01\x12\x33\n\x0fprincipal_epoch\x18\x07 \x01(\x04\x42\n\xbaH\x07\x32\x02(\x01\xc8\x01\x01R\x0eprincipalEpochB\x0b\n\t_agent_idB\x12\n\x10_organization_idJ\x04\x08\x08\x10\tJ\x04\x08\t\x10\nR\x0cimage_digestR\x11\x62uild_incarnation\"\xa6\x02\n\x0eWorkGrantHopV1\x12%\n\x08grant_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x07grantId\x12I\n\tapprovers\x18\x02 \x03(\x0b\x32\x1f.codefly.base.v0.WorkApproverV1B\n\xbaH\x07\x92\x01\x04\x08\x01\x10@R\tapprovers\x12I\n\rgranted_scope\x18\x03 \x01(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1B\x06\xbaH\x03\xc8\x01\x01R\x0cgrantedScope\x12$\n\x07subject\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x07subject\x12\x31\n\x0erequest_digest\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\rrequestDigest\"r\n\x0eWorkApproverV1\x12-\n\x0cprincipal_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0bprincipalId\x12\x31\n\x0eprincipal_kind\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\rprincipalKind\"\x8f\x02\n\x12\x41pprovalRequiredV1\x12)\n\nrequest_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\trequestId\x12M\n\x0frequested_scope\x18\x02 \x01(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1B\x06\xbaH\x03\xc8\x01\x01R\x0erequestedScope\x12$\n\x07subject\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x07subject\x12\x31\n\x0erequest_digest\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\rrequestDigest\x12&\n\x08\x61udience\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08\x61udienceb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"codefly/base/v0/work_context.proto\x12\x0f\x63odefly.base.v0\x1a\x1b\x62uf/validate/validate.proto\"\xa4\x0c\n\rWorkContextV1\x12\x30\n\x03typ\x18\x01 \x01(\tB\x1e\xbaH\x1br\x19\n\x17\x63odefly.work-context/v1R\x03typ\x12,\n\talgorithm\x18\x02 \x01(\tB\x0e\xbaH\x0br\t\n\x07\x45\x64\x32\x35\x35\x31\x39R\talgorithm\x12!\n\x06key_id\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\x05keyId\x12\"\n\x06issuer\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x06issuer\x12&\n\x08\x61udience\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08\x61udience\x12&\n\x0fnot_before_unix\x18\x06 \x01(\x03R\rnotBeforeUnix\x12$\n\x0eissued_at_unix\x18\x07 \x01(\x03R\x0cissuedAtUnix\x12&\n\x0f\x65xpires_at_unix\x18\x08 \x01(\x03R\rexpiresAtUnix\x12 \n\x05nonce\x18\t \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x05nonce\x12\x35\n\x16\x61uthorization_revision\x18\n \x01(\x04R\x15\x61uthorizationRevision\x12\x42\n\rreplay_policy\x18\x0b \x01(\tB\x1d\xbaH\x1ar\x18R\nidempotentR\nsingle-useR\x0creplayPolicy\x12\'\n\ttenant_id\x18\x0c \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08tenantId\x12\x38\n\x12owner_principal_id\x18\r \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x10ownerPrincipalId\x12#\n\x07task_id\x18\x0e \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x06taskId\x12)\n\nsession_id\x18\x0f \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\tsessionId\x12;\n\x11parent_session_id\x18\x10 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x00R\x0fparentSessionId\x88\x01\x01\x12G\n\x10\x61uthority_scopes\x18\x11 \x03(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1R\x0f\x61uthorityScopes\x12=\n\x0b\x61\x63tor_chain\x18\x12 \x03(\x0b\x32\x1c.codefly.base.v0.WorkActorV1R\nactorChain\x12\x30\n\x14\x61ttribution_team_ids\x18\x13 \x03(\tR\x12\x61ttributionTeamIds\x12\x32\n\x0cworkspace_id\x18\x14 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x01R\x0bworkspaceId\x88\x01\x01\x12.\n\nproject_id\x18\x15 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x02R\tprojectId\x88\x01\x01\x12\x41\n\x14owner_principal_kind\x18\x16 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01H\x03R\x12ownerPrincipalKind\x88\x01\x01\x12\x35\n\x0eowner_agent_id\x18\x17 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x04R\x0cownerAgentId\x88\x01\x01\x12\x38\n\x0forganization_id\x18\x19 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x05R\x0eorganizationId\x88\x01\x01\x12\x41\n\tgrant_hop\x18\x18 \x01(\x0b\x32\x1f.codefly.base.v0.WorkGrantHopV1H\x06R\x08grantHop\x88\x01\x01\x12\x37\n\x04seal\x18\x1a \x01(\x0b\x32\x1b.codefly.base.v0.WorkSealV1B\x06\xbaH\x03\xc8\x01\x01R\x04seal\x12Y\n\x11operation_binding\x18\x1b \x01(\x0b\x32\'.codefly.base.v0.WorkOperationBindingV1H\x07R\x10operationBinding\x88\x01\x01\x42\x14\n\x12_parent_session_idB\x0f\n\r_workspace_idB\r\n\x0b_project_idB\x17\n\x15_owner_principal_kindB\x11\n\x0f_owner_agent_idB\x12\n\x10_organization_idB\x0c\n\n_grant_hopB\x14\n\x12_operation_binding\"\xde\x01\n\nWorkSealV1\x12\x30\n\x0fprincipal_epoch\x18\x01 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x0eprincipalEpoch\x12\x33\n\x0finstallation_id\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0einstallationId\x12<\n\x15installation_revision\x18\x03 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x14installationRevisionJ\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06R\x11\x62uild_incarnationR\x0cimage_digest\"\x93\x01\n\x16WorkOperationBindingV1\x12)\n\nbinding_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\tbindingId\x12#\n\x08revision\x18\x02 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x08revision\x12)\n\x0bincarnation\x18\x03 \x01(\x04\x42\x07\xbaH\x04\x32\x02(\x01R\x0bincarnation\"\xc0\x01\n\x0bWorkScopeV1\x12/\n\rresource_kind\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\x0cresourceKind\x12\x18\n\x07\x61\x63tions\x18\x02 \x03(\tR\x07\x61\x63tions\x12!\n\x0cresource_ids\x18\x03 \x03(\tR\x0bresourceIds\x12\x43\n\x11resource_owner_id\x18\x04 \x01(\tB\x17\xbaH\x14r\x12\x18\x80\x04\x32\r^[^\\s\\x00*]*$R\x0fresourceOwnerId\"\xce\x03\n\x0bWorkActorV1\x12-\n\x0cprincipal_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0bprincipalId\x12\x31\n\x0eprincipal_kind\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\rprincipalKind\x12/\n\rdelegation_id\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0c\x64\x65legationId\x12\x43\n\x0egranted_scopes\x18\x04 \x03(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1R\rgrantedScopes\x12*\n\x08\x61gent_id\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x00R\x07\x61gentId\x88\x01\x01\x12\x38\n\x0forganization_id\x18\x06 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04H\x01R\x0eorganizationId\x88\x01\x01\x12\x33\n\x0fprincipal_epoch\x18\x07 \x01(\x04\x42\n\xbaH\x07\x32\x02(\x01\xc8\x01\x01R\x0eprincipalEpochB\x0b\n\t_agent_idB\x12\n\x10_organization_idJ\x04\x08\x08\x10\tJ\x04\x08\t\x10\nR\x0cimage_digestR\x11\x62uild_incarnation\"\xa6\x02\n\x0eWorkGrantHopV1\x12%\n\x08grant_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x07grantId\x12I\n\tapprovers\x18\x02 \x03(\x0b\x32\x1f.codefly.base.v0.WorkApproverV1B\n\xbaH\x07\x92\x01\x04\x08\x01\x10@R\tapprovers\x12I\n\rgranted_scope\x18\x03 \x01(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1B\x06\xbaH\x03\xc8\x01\x01R\x0cgrantedScope\x12$\n\x07subject\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x07subject\x12\x31\n\x0erequest_digest\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\rrequestDigest\"r\n\x0eWorkApproverV1\x12-\n\x0cprincipal_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x0bprincipalId\x12\x31\n\x0eprincipal_kind\x18\x02 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x01R\rprincipalKind\"\x8f\x02\n\x12\x41pprovalRequiredV1\x12)\n\nrequest_id\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\trequestId\x12M\n\x0frequested_scope\x18\x02 \x01(\x0b\x32\x1c.codefly.base.v0.WorkScopeV1B\x06\xbaH\x03\xc8\x01\x01R\x0erequestedScope\x12$\n\x07subject\x18\x03 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x07subject\x12\x31\n\x0erequest_digest\x18\x04 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\rrequestDigest\x12&\n\x08\x61udience\x18\x05 \x01(\tB\n\xbaH\x07r\x05\x10\x01\x18\x80\x04R\x08\x61udienceb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -82,6 +82,8 @@ _globals['_WORKOPERATIONBINDINGV1'].fields_by_name['incarnation']._serialized_options = b'\272H\0042\002(\001' _globals['_WORKSCOPEV1'].fields_by_name['resource_kind']._loaded_options = None _globals['_WORKSCOPEV1'].fields_by_name['resource_kind']._serialized_options = b'\272H\007r\005\020\001\030\200\001' + _globals['_WORKSCOPEV1'].fields_by_name['resource_owner_id']._loaded_options = None + _globals['_WORKSCOPEV1'].fields_by_name['resource_owner_id']._serialized_options = b'\272H\024r\022\030\200\0042\r^[^\\s\\x00*]*$' _globals['_WORKACTORV1'].fields_by_name['principal_id']._loaded_options = None _globals['_WORKACTORV1'].fields_by_name['principal_id']._serialized_options = b'\272H\007r\005\020\001\030\200\004' _globals['_WORKACTORV1'].fields_by_name['principal_kind']._loaded_options = None @@ -124,14 +126,14 @@ _globals['_WORKSEALV1']._serialized_end=1882 _globals['_WORKOPERATIONBINDINGV1']._serialized_start=1885 _globals['_WORKOPERATIONBINDINGV1']._serialized_end=2032 - _globals['_WORKSCOPEV1']._serialized_start=2034 - _globals['_WORKSCOPEV1']._serialized_end=2157 - _globals['_WORKACTORV1']._serialized_start=2160 - _globals['_WORKACTORV1']._serialized_end=2622 - _globals['_WORKGRANTHOPV1']._serialized_start=2625 - _globals['_WORKGRANTHOPV1']._serialized_end=2919 - _globals['_WORKAPPROVERV1']._serialized_start=2921 - _globals['_WORKAPPROVERV1']._serialized_end=3035 - _globals['_APPROVALREQUIREDV1']._serialized_start=3038 - _globals['_APPROVALREQUIREDV1']._serialized_end=3309 + _globals['_WORKSCOPEV1']._serialized_start=2035 + _globals['_WORKSCOPEV1']._serialized_end=2227 + _globals['_WORKACTORV1']._serialized_start=2230 + _globals['_WORKACTORV1']._serialized_end=2692 + _globals['_WORKGRANTHOPV1']._serialized_start=2695 + _globals['_WORKGRANTHOPV1']._serialized_end=2989 + _globals['_WORKAPPROVERV1']._serialized_start=2991 + _globals['_WORKAPPROVERV1']._serialized_end=3105 + _globals['_APPROVALREQUIREDV1']._serialized_start=3108 + _globals['_APPROVALREQUIREDV1']._serialized_end=3379 # @@protoc_insertion_point(module_scope) diff --git a/proto/codefly/base/v0/work_context.proto b/proto/codefly/base/v0/work_context.proto index 921cd228..1bcb633d 100644 --- a/proto/codefly/base/v0/work_context.proto +++ b/proto/codefly/base/v0/work_context.proto @@ -187,7 +187,10 @@ message WorkOperationBindingV1 { // WorkScopeV1 is a structured, product-neutral capability scope. Empty // resource_ids means every resource of resource_kind; a child may narrow that -// wildcard to explicit IDs but may never widen an explicit parent set. +// wildcard to explicit IDs but may never widen an explicit parent set. A +// non-empty resource_owner_id narrows the scope to the resources that +// principal owns; a child may add an owner to a scope that names none, but +// may never drop or change one. message WorkScopeV1 { // resource_kind is the class of resource the scope grants against. string resource_kind = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; @@ -196,6 +199,12 @@ message WorkScopeV1 { // resource_ids narrows the scope to specific resources. Empty means every // resource of resource_kind. repeated string resource_ids = 3; + // resource_owner_id narrows the scope to the resources this principal owns + // (an installed solution's principal). Empty means the scope selects no + // owner. With resource_ids, a resource is covered only when both match. A + // reader that cannot compare a resource's owner must not treat an + // owner-selected scope as covering the whole kind. + string resource_owner_id = 4 [(buf.validate.field).string.max_len = 512, (buf.validate.field).string.pattern = "^[^\\s\\x00*]*$"]; } // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's diff --git a/runnable/operation.go b/runnable/operation.go index 08d4866c..6cfcf2dd 100644 --- a/runnable/operation.go +++ b/runnable/operation.go @@ -424,6 +424,9 @@ func (a authoritySpec) validateScopes(at string, scopes []*basev0.WorkScopeV1) e if len(scope.GetActions()) == 0 || len(scope.GetActions()) > MaxScopeActions { return fmt.Errorf("%w: %s %s scope %q names %d actions; between 1 and %d are required", ErrInvalid, a.subject, at, kind, len(scope.GetActions()), MaxScopeActions) } + if owner := scope.GetResourceOwnerId(); owner != "" { + return fmt.Errorf("%w: %s %s scope %q selects resource owner %q; an owner is the authority's to select for an installation, never declared", ErrInvalid, a.subject, at, kind, owner) + } if len(scope.GetResourceIds()) > MaxScopeResourceIds { return fmt.Errorf("%w: %s %s scope %q names %d resource ids; at most %d may be declared", ErrInvalid, a.subject, at, kind, len(scope.GetResourceIds()), MaxScopeResourceIds) } diff --git a/runnable/operation_test.go b/runnable/operation_test.go index 853abc31..f83dca04 100644 --- a/runnable/operation_test.go +++ b/runnable/operation_test.go @@ -286,6 +286,9 @@ func TestOperationFromMethodEnforcesThePolicyBounds(t *testing.T) { } o.InvokeScopes = []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"ingest", "read"}, ResourceIds: ids}} }, "resource ids"}, + {"a declared resource owner", func(o *runnablev0.Operation) { + o.InvokeScopes = []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"ingest", "read"}, ResourceOwnerId: "principal-u"}} + }, "never declared"}, {"repeated resource id", func(o *runnablev0.Operation) { o.InvokeScopes = []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"ingest", "read"}, ResourceIds: []string{"one", "one"}}} }, "twice"}, diff --git a/workcontext/owner_test.go b/workcontext/owner_test.go new file mode 100644 index 00000000..0dda3239 --- /dev/null +++ b/workcontext/owner_test.go @@ -0,0 +1,79 @@ +package workcontext_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" +) + +func owned(kind string, actions, ids []string, owner string) *basev0.WorkScopeV1 { + s := scope(kind, actions, ids) + s.ResourceOwnerId = owner + return s +} + +// An owner narrows a scope like ids do: a child may add one, never drop or +// change it, and a question naming no owner is never answered by an +// owner-selected scope. +func TestScopeContained_ResourceOwner(t *testing.T) { + for name, tc := range map[string]struct { + child *basev0.WorkScopeV1 + parents []*basev0.WorkScopeV1 + contained bool + }{ + "the same owner": {owned("doc", []string{"read"}, nil, "u"), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "u")}, true}, + "ids within the owner": {owned("doc", []string{"read"}, []string{"a"}, "u"), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "u")}, true}, + "an owner added to a whole kind": {owned("doc", []string{"read"}, nil, "u"), []*basev0.WorkScopeV1{scope("doc", []string{"read"}, nil)}, true}, + "an owner added to named ids": {owned("doc", []string{"read"}, []string{"a"}, "u"), []*basev0.WorkScopeV1{scope("doc", []string{"read"}, []string{"a"})}, true}, + "another owner": {owned("doc", []string{"read"}, nil, "v"), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "u")}, false}, + "the owner dropped": {scope("doc", []string{"read"}, nil), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "u")}, false}, + "an id asked without its owner": {scope("doc", []string{"read"}, []string{"a"}), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "u")}, false}, + "the owner's ids widened": {owned("doc", []string{"read"}, nil, "u"), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, []string{"a"}, "u")}, false}, + "an id outside the owner's": {owned("doc", []string{"read"}, []string{"b"}, "u"), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, []string{"a"}, "u")}, false}, + "another owner's row beside a plain row": {owned("doc", []string{"read"}, []string{"a"}, "v"), []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "u"), scope("doc", []string{"read"}, []string{"a"})}, true}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.contained, workcontext.ScopeContained(tc.child, tc.parents)) + }) + } +} + +// A capability carrying owner-selected scopes is signed, verified and +// narrowed like any other; a child cannot drop the owner, and an owner the +// schema refuses never mints. +func TestOwnerSelectedScopesMintVerifyAndAttenuate(t *testing.T) { + h := newHarness(t) + start := func(scopes ...*basev0.WorkScopeV1) (string, error) { + token, _, err := h.authority.Start(context.Background(), workcontext.StartInput{ + InstallationID: installation, TenantID: tenant, OwnerPrincipalID: ownerID, OwnerPrincipalKind: "human", + OrganizationID: organization, TaskID: taskID, Audience: audience, AuthorityScopes: scopes, TTL: time.Hour, + }) + return token, err + } + token, err := start(owned("doc", []string{"read", "write"}, nil, "principal-u")) + require.NoError(t, err) + parent := h.mustVerify(audience, token) + require.Equal(t, "principal-u", parent.EffectiveScopes()[0].GetResourceOwnerId()) + + child := func(scopes ...*basev0.WorkScopeV1) error { + _, _, err := h.authority.Child(context.Background(), parent, workcontext.ChildInput{ + PrincipalID: agentID, PrincipalKind: "agent", AgentID: "fixture.test/agent:1.0.0", DelegationID: "d-1", + GrantedScopes: scopes, Audience: audience, TTL: 30 * time.Minute, + }) + return err + } + require.NoError(t, child(owned("doc", []string{"read"}, []string{"a"}, "principal-u"))) + require.ErrorIs(t, child(scope("doc", []string{"read"}, nil)), workcontext.ErrInvalid) + require.ErrorIs(t, child(owned("doc", []string{"read"}, nil, "principal-v")), workcontext.ErrInvalid) + + for _, malformed := range []string{" principal-u", "principal u", "principal-u\n", "*", strings.Repeat("u", 513)} { + _, err := start(owned("doc", []string{"read"}, nil, malformed)) + require.Error(t, err, "owner %q", malformed) + } +} diff --git a/workcontext/scope.go b/workcontext/scope.go index bc2c8a5a..04badadb 100644 --- a/workcontext/scope.go +++ b/workcontext/scope.go @@ -9,6 +9,11 @@ import ( // ScopeContained applies the Work Context attenuation rule to one scope: a // child may narrow a wildcard parent to explicit ids but may never widen an // explicit parent set, and may never name an action the parent does not hold. +// The resource owner narrows the same way: a parent row naming an owner +// covers only a child naming that same owner, so a child can add an owner to +// a row that names none but never drop or change one. A reader asking about a +// resource asks with the resource's owner; an ask naming no owner is never +// covered by an owner-selected row, so an owner-blind check fails closed. func ScopeContained(child *basev0.WorkScopeV1, parents []*basev0.WorkScopeV1) bool { // A kind can have several rows with different action/resource coverage. // Check each pair against that coverage, never their Cartesian product. @@ -26,6 +31,9 @@ func ScopeContained(child *basev0.WorkScopeV1, parents []*basev0.WorkScopeV1) bo if parent.GetResourceKind() != child.GetResourceKind() || !slices.Contains(parent.GetActions(), action) { continue } + if owner := parent.GetResourceOwnerId(); owner != "" && owner != child.GetResourceOwnerId() { + continue + } if len(parent.GetResourceIds()) == 0 { wholeKind = true break