diff --git a/docs/runnable.md b/docs/runnable.md index 4fa4f8c4..640a78d5 100644 --- a/docs/runnable.md +++ b/docs/runnable.md @@ -478,7 +478,10 @@ learnable only through its receipt, and no runtime mints a lookup under the invoke scopes yet. A `RECOVERY_RECOMPUTE` operation has no receipt at all: no lookup scopes, no lookup method, and only `COMPLETION_CALL`. An owner declaration may defer scopes to its [required scope slots](#required-scope-slots); supplied scopes carry the runtime's -own value bounds. An operation with no +own value bounds. No scope here names a `resource_owner_id` — not a declaration, +a binding or a slot selection: an owner is inherited from the capability the +operation is invoked under, with `workcontext.InheritResourceOwner` +([Work Context](work-context.md#scopes-and-the-resource-owner)). An operation with no audience and no scopes is not a lenient one: there is nothing to mint a child capability from, and the runtime refuses the installation rather than calling without authority. `lookup_scopes` must be a subset of `invoke_scopes` under diff --git a/docs/work-context.md b/docs/work-context.md index c80b0453..f8f07426 100644 --- a/docs/work-context.md +++ b/docs/work-context.md @@ -87,17 +87,58 @@ 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. +### Scopes and the resource owner + `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. +a resource is covered only when both match. The owner is at most 512 bytes of +UTF-8 with no ASCII or Unicode whitespace, no control, format (zero-width) or +private-use character and no `*` — the schema's rule, which `protovalidate` +enforces at mint and at decode — so two owners that print alike are the same +bytes. + +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 **built on +`ScopeContained`** (or `ScopesAttenuate`) fails closed rather than reading the +scope as the whole kind. That guarantee is the function's, not the field's. A +reader that inspects `WorkScopeV1` itself — flattening a scope with no ids to +`kind:action`, or copying kind, actions and ids into a type of its own — reads +an owner-selected scope as the whole kind once its core pin knows field 4, +because the unknown-field refusal that used to reject the whole token no longer +fires. Until such a reader compares owners it must refuse, or grant nothing +from, any scope with a non-empty `resource_owner_id`, and an issuer must not +mint an owner-selected scope to its audience. The readers known to inspect +scopes directly at the time of writing: + +| Reader | Where | Owner-blind because | +| --- | --- | --- | +| authority hop library (Go) | `libraries/hop/go/hop.go` (`flatten`) | stamps `kind:action` for every scope without ids, so `Identity.Has` widens | +| authority client (Python) | `authority_client/work_context.py` (`Scope`, `scope_contained`) | the `Scope` tuple drops the owner and containment ignores it | +| authority hop (Python) | `codefly_hop` (`_flatten`) | same flattening as the Go hop | +| module-annotations | `pkg/infra/workcontext/authority.go` | reads kind, actions and ids field by field | +| module-evaluation | `identity.go` | reads kind, actions and ids field by field | +| module-record-store | `adapters/operations.go` | reads kind, actions and ids field by field | +| cli execution recorder | `pkg/executionrecorder/work_context_authority.go` | reads kind, actions and ids field by field | + +The authority's `Claims.Allows` builds on `ScopeContained` and fails closed. + +Runnable operations never carry an owner: not in the owner's declaration, not +in an installation's binding, not in a composition's scope-slot selection — +each refusal says which, and says `owner-scoped parent`. The owner comes from +the capability the operation is invoked under. A runtime minting an operation +child under an owner-scoped parent passes the operation's owner-less scopes +through `workcontext.InheritResourceOwner(requested, parent.EffectiveScopes())` +and mints the child with the result: a scope the parent covers as written is +kept, one the parent covers only under an owner gets that owner, and it is +refused — never guessed — when no owner covers it, when several do (mint one +child per owner), or when the scope already names another owner. Minting the +operation's scopes as written under an owner-scoped parent is refused by +`Child` as a widening, because an owner-less child is never covered by an +owner row. 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 abeedf00..54bef483 100644 --- a/generated/go/codefly/base/v0/work_context.pb.go +++ b/generated/go/codefly/base/v0/work_context.pb.go @@ -545,7 +545,10 @@ type WorkScopeV1 struct { // (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. + // owner-selected scope as covering the whole kind. It is at most 512 bytes + // of UTF-8 and carries no whitespace (ASCII or Unicode separators), no + // control, format (zero-width) or private-use character, and no "*", so two + // owners that print alike are the same bytes. ResourceOwnerId string `protobuf:"bytes,4,opt,name=resource_owner_id,json=resourceOwnerId,proto3" json:"resource_owner_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1038,13 +1041,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\"\xc0\x01\n" + + "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"\xc6\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\x12C\n" + - "\x11resource_owner_id\x18\x04 \x01(\tB\x17\xbaH\x14r\x12\x18\x80\x042\r^[^\\s\\x00*]*$R\x0fresourceOwnerId\"\xce\x03\n" + + "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\x12I\n" + + "\x11resource_owner_id\x18\x04 \x01(\tB\x1d\xbaH\x1ar\x18(\x80\x042\x13^[^\\s\\p{Z}\\p{C}*]*$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 a72793cc..0db89123 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\"\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') +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\"\xc6\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\x12I\n\x11resource_owner_id\x18\x04 \x01(\tB\x1d\xbaH\x1ar\x18(\x80\x04\x32\x13^[^\\s\\p{Z}\\p{C}*]*$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) @@ -83,7 +83,7 @@ _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['_WORKSCOPEV1'].fields_by_name['resource_owner_id']._serialized_options = b'\272H\032r\030(\200\0042\023^[^\\s\\p{Z}\\p{C}*]*$' _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 @@ -127,13 +127,13 @@ _globals['_WORKOPERATIONBINDINGV1']._serialized_start=1885 _globals['_WORKOPERATIONBINDINGV1']._serialized_end=2032 _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 + _globals['_WORKSCOPEV1']._serialized_end=2233 + _globals['_WORKACTORV1']._serialized_start=2236 + _globals['_WORKACTORV1']._serialized_end=2698 + _globals['_WORKGRANTHOPV1']._serialized_start=2701 + _globals['_WORKGRANTHOPV1']._serialized_end=2995 + _globals['_WORKAPPROVERV1']._serialized_start=2997 + _globals['_WORKAPPROVERV1']._serialized_end=3111 + _globals['_APPROVALREQUIREDV1']._serialized_start=3114 + _globals['_APPROVALREQUIREDV1']._serialized_end=3385 # @@protoc_insertion_point(module_scope) diff --git a/proto/codefly/base/v0/work_context.proto b/proto/codefly/base/v0/work_context.proto index 1bcb633d..8482062a 100644 --- a/proto/codefly/base/v0/work_context.proto +++ b/proto/codefly/base/v0/work_context.proto @@ -203,8 +203,11 @@ message WorkScopeV1 { // (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*]*$"]; + // owner-selected scope as covering the whole kind. It is at most 512 bytes + // of UTF-8 and carries no whitespace (ASCII or Unicode separators), no + // control, format (zero-width) or private-use character, and no "*", so two + // owners that print alike are the same bytes. + string resource_owner_id = 4 [(buf.validate.field).string.max_bytes = 512, (buf.validate.field).string.pattern = "^[^\\s\\p{Z}\\p{C}*]*$"]; } // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's diff --git a/runnable/operation.go b/runnable/operation.go index 6dad6ee4..61290686 100644 --- a/runnable/operation.go +++ b/runnable/operation.go @@ -492,6 +492,7 @@ func (s *OperationSpec) validateRecovery() error { func (s *OperationSpec) authority() authoritySpec { return authoritySpec{ subject: s.Method, + origin: "declaration", audience: s.Audience, invokeScopes: s.InvokeScopes, lookupScopes: s.LookupScopes, @@ -520,7 +521,10 @@ func (s *OperationSpec) authority() authoritySpec { type authoritySpec struct { // subject names what the authority belongs to, for the error message: an // operation spelling, or a binding's facility. - subject string + subject string + // origin says where the scopes were written — the owner's declaration or + // an installation's binding — so a refusal names the record to correct. + origin string audience string invokeScopes []*basev0.WorkScopeV1 lookupScopes []*basev0.WorkScopeV1 @@ -591,7 +595,7 @@ func (a authoritySpec) validateScopes(at string, scopes []*basev0.WorkScopeV1) e 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) + return fmt.Errorf("%w: %s %s scope %q selects resource owner %q in its %s; %s", ErrInvalid, a.subject, at, kind, owner, a.origin, ownerScopedParent) } 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) @@ -606,6 +610,14 @@ func (a authoritySpec) validateScopes(at string, scopes []*basev0.WorkScopeV1) e return nil } +// ownerScopedParent is why no operation authority names a resource owner. An +// owner comes from the capability the operation is invoked under; the runtime +// carries it onto the operation child with workcontext.InheritResourceOwner, +// so a declaration, a binding or a slot selection naming one would pin every +// invocation to one owner whatever capability it ran under. +const ownerScopedParent = "owner-scoped parent: a resource owner is inherited from the capability the operation is invoked under " + + "(workcontext.InheritResourceOwner, when the runtime mints the operation child), never written into an operation's authority" + func (a authoritySpec) validateScopeValues(at, kind, what string, values []string, bound int) error { seen := make(map[string]struct{}, len(values)) for _, value := range values { diff --git a/runnable/operation_test.go b/runnable/operation_test.go index 7bd93537..6edf808f 100644 --- a/runnable/operation_test.go +++ b/runnable/operation_test.go @@ -292,7 +292,7 @@ func TestOperationFromMethodEnforcesThePolicyBounds(t *testing.T) { }, "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"}, + }, "selects resource owner \"principal-u\" in its declaration; owner-scoped parent:"}, {"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/runnable/package.go b/runnable/package.go index dcf68a63..edb25501 100644 --- a/runnable/package.go +++ b/runnable/package.go @@ -563,6 +563,7 @@ func validateBindingAuthority(binding *basev0.RunnableBinding, pkg *basev0.Runna } return authoritySpec{ subject: "binding on facility " + binding.GetFacility().GetKind().String(), + origin: "installation binding", audience: authority.GetAudience(), invokeScopes: authority.GetInvokeScopes(), lookupScopes: authority.GetLookupScopes(), diff --git a/runnable/package_test.go b/runnable/package_test.go index 082493b4..d072ec13 100644 --- a/runnable/package_test.go +++ b/runnable/package_test.go @@ -943,6 +943,17 @@ func TestEveryFacilityInstallsUnderAWorkContext(t *testing.T) { _, err = runnable.PrepareBinding(widened, pkg) require.ErrorIs(t, err, runnable.ErrInvalid) require.ErrorContains(t, err, "read-only") + + // An owner written into an installation's authority is refused as + // the installation's, with the reason: the owner comes from the + // capability the operation is invoked under. + _, owned := packageForFacility(t, facility) + owned.Authority = sampleAuthority() + owned.Authority.InvokeScopes[0].ResourceOwnerId = "principal-u" + _, err = runnable.PrepareBinding(owned, pkg) + require.ErrorIs(t, err, runnable.ErrInvalid) + require.ErrorContains(t, err, "in its installation binding; owner-scoped parent:") + require.ErrorContains(t, err, facility.String()) }) } } diff --git a/runnable/scope_slots.go b/runnable/scope_slots.go index 0bfdfee7..c1075492 100644 --- a/runnable/scope_slots.go +++ b/runnable/scope_slots.go @@ -213,6 +213,9 @@ func (s *OperationSpec) validateSelectedScope(slot, at string, scope *basev0.Wor if !boundedScopeValue(kind, MaxScopeKindLength) || strings.Contains(kind, "*") { return fmt.Errorf("%w: %s scope slot %q selects an %s scope with resource kind %q, which is not an exact kind", ErrInvalid, s.Method, slot, at, kind) } + if owner := scope.GetResourceOwnerId(); owner != "" { + return fmt.Errorf("%w: %s scope slot %q %s scope over kind %q selects resource owner %q in the composition's selection; %s", ErrInvalid, s.Method, slot, at, kind, owner, ownerScopedParent) + } actions := scope.GetActions() if len(actions) == 0 || len(actions) > MaxScopeActions { return fmt.Errorf("%w: %s scope slot %q %s scope over kind %q names %d actions; between 1 and %d are required", ErrInvalid, s.Method, slot, at, kind, len(actions), MaxScopeActions) diff --git a/runnable/scope_slots_test.go b/runnable/scope_slots_test.go index d2de19e6..6c7fb6b2 100644 --- a/runnable/scope_slots_test.go +++ b/runnable/scope_slots_test.go @@ -323,6 +323,14 @@ func TestScopeSelectionsAreRefusedWhenTheyDoNotAnswerTheSlots(t *testing.T) { s[1].Invoke[0].ResourceIds = []string{"search-1", "search-1"} return s }, `scope slot "tools" invoke scope over kind "acme.tool/search" names resource id "search-1" twice`}, + "an owner in the selection": {func(s []*runnablev0.ScopeSelection) []*runnablev0.ScopeSelection { + s[1].Invoke[0].ResourceOwnerId = "principal-u" + return s + }, `scope slot "tools" invoke scope over kind "acme.tool/search" selects resource owner "principal-u" in the composition's selection; owner-scoped parent:`}, + "an owner in a lookup selection": {func(s []*runnablev0.ScopeSelection) []*runnablev0.ScopeSelection { + s[1].Lookup[0].ResourceOwnerId = "principal-u" + return s + }, `lookup scope over kind "acme.tool/calendar" selects resource owner "principal-u" in the composition's selection; owner-scoped parent:`}, "no actions": {func(s []*runnablev0.ScopeSelection) []*runnablev0.ScopeSelection { s[1].Invoke[0].Actions = nil return s diff --git a/workcontext/decode_test.go b/workcontext/decode_test.go index 864ce655..2a91754e 100644 --- a/workcontext/decode_test.go +++ b/workcontext/decode_test.go @@ -58,6 +58,10 @@ func TestDecodeAgreesWithTheKitOnEveryStructuralRefusal(t *testing.T) { // read refuses it, and no entrypoint ever compares an empty audience // to anything. "empty-audience": workcontext.ErrInvalid, + // A hop that drops or changes its parent's resource owner widens the + // chain, and attenuation is structure: Decode runs checkStructure. + "owner-dropped-by-hop": workcontext.ErrInvalid, + "owner-changed-by-hop": workcontext.ErrInvalid, } seen := map[string]bool{} for _, fixture := range fixtures { diff --git a/workcontext/fixtures.go b/workcontext/fixtures.go index c368a1d1..27674fac 100644 --- a/workcontext/fixtures.go +++ b/workcontext/fixtures.go @@ -122,6 +122,13 @@ const ( FixtureGrantSubject = "resource-conformance" // FixtureGrantRequestDigest is the call that grant was approved for. FixtureGrantRequestDigest = "sha256:conformance" + + // FixtureResourceOwner is the resource owner the owner-selected fixtures + // name: the principal whose resources their scopes cover. + FixtureResourceOwner = "principal-resource-owner" + // FixtureOtherResourceOwner is an owner no fixture's authority selects; + // a hop naming it changes the owner. + FixtureOtherResourceOwner = "principal-resource-owner-other" ) // Form is which shape of capability a fixture is. Every form this package mints @@ -474,6 +481,11 @@ func Fixtures(now time.Time) ([]Fixture, error) { return nil, err } fixtures = append(fixtures, negatives...) + owned, err := fixtureOwnerSelected(ctx, authority) + if err != nil { + return nil, err + } + fixtures = append(fixtures, owned...) sort.Slice(fixtures, func(i, j int) bool { return fixtures[i].Name < fixtures[j].Name }) return fixtures, nil } @@ -669,15 +681,7 @@ func fixtureNegatives(ctx context.Context, now time.Time, session, delegated *Ve // Rewritten capabilities, re-signed with the fixture key so the signature // is genuine and the rule being tested is the one that refuses them. - _, private := FixtureKeyPair() - resign := func(wc *basev0.WorkContextV1) (string, error) { - payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(wc) - if err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(payload) + "." + - base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, payload)), nil - } + resign := fixtureResign unsealed := cloneClaims(session.Context()) unsealed.Seal = nil @@ -1049,6 +1053,98 @@ func fixtureNegatives(ctx context.Context, now time.Time, session, delegated *Ve return fixtures, nil } +// fixtureResign signs rewritten claims with the fixture key, so the signature +// is genuine and the rule a fixture tests is the one that refuses it. +func fixtureResign(wc *basev0.WorkContextV1) (string, error) { + _, private := FixtureKeyPair() + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(wc) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(payload) + "." + + base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, payload)), nil +} + +// fixtureOwnerSelected mints the resource-owner vectors: a session whose +// authority selects FixtureResourceOwner's records, one delegation hop that +// narrows within that owner (accepted), and that hop rewritten to drop the +// owner and to change it, re-signed so only attenuation refuses them. A +// second implementation that compares kind, actions and ids but not the owner +// accepts both rewrites, and these are the fixtures that say so. +func fixtureOwnerSelected(ctx context.Context, authority *Authority) ([]Fixture, error) { + owned := func(id, owner string) *basev0.WorkScopeV1 { + scope := fixtureScope("record", "read", id) + scope.ResourceOwnerId = owner + return scope + } + token, _, err := authority.Start(ctx, StartInput{ + TenantID: FixtureTenant, + OwnerPrincipalID: FixturePrincipal, + OwnerPrincipalKind: "human", + OrganizationID: FixtureOrganization, + TaskID: FixtureTask, + Audience: FixtureAudience, + AuthorityScopes: []*basev0.WorkScopeV1{owned("", FixtureResourceOwner)}, + InstallationID: FixtureInstallation, + TTL: time.Hour, + }) + if err != nil { + return nil, fmt.Errorf("work context fixtures: owner-selected session: %w", err) + } + parent, err := fixtureVerify(ctx, authority, token) + if err != nil { + return nil, err + } + delegated, _, err := authority.Child(ctx, parent, ChildInput{ + PrincipalID: FixtureActor, + PrincipalKind: "agent", + AgentID: FixtureAgentID, + DelegationID: "delegation-conformance-owner", + GrantedScopes: []*basev0.WorkScopeV1{owned("record-conformance", FixtureResourceOwner)}, + Audience: FixtureAudience, + TTL: time.Hour, + }) + if err != nil { + return nil, fmt.Errorf("work context fixtures: owner-selected delegation: %w", err) + } + verified, err := fixtureVerify(ctx, authority, delegated) + if err != nil { + return nil, err + } + fixtures := []Fixture{{ + Name: "owner-delegated", Form: FormDelegated, Token: delegated, Outcome: OutcomeAccepted, + Reason: "a session whose authority selects one resource owner's records, and a delegation hop that narrows " + + "to named ids within that same owner; an owner narrows a scope the way ids do", + }} + for _, rewrite := range []struct { + name, owner, rule string + }{ + { + name: "owner-dropped-by-hop", owner: "", + rule: "a genuinely signed delegation hop that drops its parent's resource owner, which would read one " + + "owner's records as the whole kind; containment refuses it as a widening", + }, + { + name: "owner-changed-by-hop", owner: FixtureOtherResourceOwner, + rule: "a genuinely signed delegation hop that names another resource owner than its parent's; an owner " + + "row covers only a child naming that same owner, so this is a widening, not a narrowing", + }, + } { + altered := cloneClaims(verified.Context()) + hop := altered.ActorChain[len(altered.ActorChain)-1] + hop.GrantedScopes = []*basev0.WorkScopeV1{owned("record-conformance", rewrite.owner)} + token, err := fixtureResign(altered) + if err != nil { + return nil, err + } + fixtures = append(fixtures, Fixture{ + Name: rewrite.name, Form: FormDelegated, Token: token, + Outcome: OutcomeRejected, Err: ErrInvalid, Message: "widens authority", Reason: rewrite.rule, + }) + } + return fixtures, nil +} + // fixtureTamper changes one SEALED VALUE and keeps the original signature, so // the result is schema-valid and the only thing wrong with it is the // signature. diff --git a/workcontext/owner_test.go b/workcontext/owner_test.go index 0dda3239..2f122a3a 100644 --- a/workcontext/owner_test.go +++ b/workcontext/owner_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/workcontext" @@ -72,8 +73,210 @@ func TestOwnerSelectedScopesMintVerifyAndAttenuate(t *testing.T) { 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) +} + +// Verify holds a presented chain to the owner rule on its own, not only +// through the minter: a genuinely signed hop that drops or changes its +// parent's owner is refused as a widening, and the sound hop it was rewritten +// from verifies. +func TestVerifyRefusesAHopThatDropsOrChangesTheOwner(t *testing.T) { + h := newHarness(t) + token, _, err := h.authority.Start(context.Background(), workcontext.StartInput{ + InstallationID: installation, TenantID: tenant, OwnerPrincipalID: ownerID, OwnerPrincipalKind: "human", + OrganizationID: organization, TaskID: taskID, Audience: audience, TTL: time.Hour, + AuthorityScopes: []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, "principal-u")}, + }) + require.NoError(t, err) + parent := h.mustVerify(audience, token) + sound, signed, err := h.authority.Child(context.Background(), parent, workcontext.ChildInput{ + PrincipalID: agentID, PrincipalKind: "agent", AgentID: "fixture.test/agent:1.0.0", DelegationID: "d-1", + GrantedScopes: []*basev0.WorkScopeV1{owned("doc", []string{"read"}, []string{"a"}, "principal-u")}, + Audience: audience, TTL: 30 * time.Minute, + }) + require.NoError(t, err) + _, err = h.verify(audience, sound) + require.NoError(t, err) + + for name, owner := range map[string]string{"dropped": "", "changed": "principal-v"} { + t.Run(name, func(t *testing.T) { + altered := proto.CloneOf(signed) + hop := altered.GetActorChain()[len(altered.GetActorChain())-1] + hop.GrantedScopes = []*basev0.WorkScopeV1{owned("doc", []string{"read"}, []string{"a"}, owner)} + _, err := h.verify(audience, h.resign(altered)) + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.ErrorContains(t, err, "widens authority") + }) + } +} + +// The owner is bounded in bytes and carries nothing that prints as nothing or +// as something else: no ASCII or Unicode whitespace, no control, format +// (zero-width) or private-use character. Two owners that look alike in an +// audit line are then the same bytes, and the schema, not each reader, says so. +func TestResourceOwnerSchema(t *testing.T) { + h := newHarness(t) + start := func(owner string) error { + _, _, err := h.authority.Start(context.Background(), workcontext.StartInput{ + InstallationID: installation, TenantID: tenant, OwnerPrincipalID: ownerID, OwnerPrincipalKind: "human", + OrganizationID: organization, TaskID: taskID, Audience: audience, TTL: time.Hour, + AuthorityScopes: []*basev0.WorkScopeV1{owned("doc", []string{"read"}, nil, owner)}, + }) + return err + } + for name, owner := range map[string]string{ + "512 ASCII bytes": strings.Repeat("u", 512), + "512 bytes of four-byte runes": strings.Repeat("\U0001F600", 128), + "an accented principal": "principal-é", + "a path-like principal": "solutions/org-1/wiki:principal", + } { + t.Run("accepts "+name, func(t *testing.T) { + require.NoError(t, start(owner)) + }) + } + for name, owner := range map[string]string{ + "513 ASCII bytes": strings.Repeat("u", 513), + "2048 bytes in 512 runes": strings.Repeat("\U0001F600", 512), + "a leading space": " principal-u", + "an inner space": "principal u", + "a trailing newline": "principal-u\n", + "a tab": "principal\tu", + "a vertical tab": "principal\vu", + "a form feed": "principal\fu", + "a non-breaking space": "principal-u\u00a0", + "an ideographic space": "principal\u3000u", + "a line separator": "principal\u2028u", + "a zero-width space": "principal-u\u200b", + "a zero-width joiner": "principal\u200du", + "a byte-order mark": "\ufeffprincipal-u", + "a NUL": "principal\x00u", + "a start-of-heading control": "principal\x01u", + "DEL": "principal\x7fu", + "a C1 control": "principal\u0085u", + "a private-use character": "principal\ue000u", + "the wildcard": "*", + "a wildcard inside": "principal-*", + } { + t.Run("refuses "+name, func(t *testing.T) { + err := start(owner) + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "resource_owner_id") + }) + } +} + +// A runtime mints an operation child from the operation's owner-less authority +// under an owner-scoped parent by inheriting the parent's owner; it never +// guesses one, never replaces one someone wrote, and never widens. +func TestInheritResourceOwner(t *testing.T) { + rows := func(scopes ...*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { return scopes } + for name, tc := range map[string]struct { + requested, parent []*basev0.WorkScopeV1 + want []*basev0.WorkScopeV1 + refusal string + }{ + "an owner-less parent keeps the scope as written": { + requested: rows(scope("doc", []string{"read"}, []string{"a"})), + parent: rows(scope("doc", []string{"read"}, nil)), + want: rows(scope("doc", []string{"read"}, []string{"a"})), + }, + "the parent's one owner is inherited": { + requested: rows(scope("doc", []string{"read"}, nil)), + parent: rows(owned("doc", []string{"read", "write"}, nil, "u")), + want: rows(owned("doc", []string{"read"}, nil, "u")), + }, + "each scope is answered on its own": { + requested: rows(scope("doc", []string{"read"}, []string{"a"}), scope("note", []string{"write"}, nil)), + parent: rows(owned("doc", []string{"read"}, nil, "u"), scope("note", []string{"write"}, nil)), + want: rows(owned("doc", []string{"read"}, []string{"a"}, "u"), scope("note", []string{"write"}, nil)), + }, + "an owner the scope already names and the parent covers is kept": { + requested: rows(owned("doc", []string{"read"}, nil, "u")), + parent: rows(owned("doc", []string{"read"}, nil, "u")), + want: rows(owned("doc", []string{"read"}, nil, "u")), + }, + "no owner covers an action the parent does not hold": { + requested: rows(scope("doc", []string{"write"}, nil)), + parent: rows(owned("doc", []string{"read"}, nil, "u")), + refusal: "not covered by the parent", + }, + "no owner covers another kind": { + requested: rows(scope("note", []string{"read"}, nil)), + parent: rows(owned("doc", []string{"read"}, nil, "u")), + refusal: "not covered by the parent", + }, + "no owner covers the whole kind under an owner's ids": { + requested: rows(scope("doc", []string{"read"}, nil)), + parent: rows(owned("doc", []string{"read"}, []string{"a"}, "u")), + refusal: "not covered by the parent", + }, + "two owners covering one scope are ambiguous": { + requested: rows(scope("doc", []string{"read"}, []string{"a"})), + parent: rows(owned("doc", []string{"read"}, nil, "u"), owned("doc", []string{"read"}, nil, "v")), + refusal: "owner-scoped parent", + }, + "an owner someone wrote is never replaced": { + requested: rows(owned("doc", []string{"read"}, nil, "v")), + parent: rows(owned("doc", []string{"read"}, nil, "u")), + refusal: `names resource owner "v"`, + }, + } { + t.Run(name, func(t *testing.T) { + requested := cloneScopes(tc.requested) + parent := cloneScopes(tc.parent) + got, err := workcontext.InheritResourceOwner(requested, parent) + require.Equal(t, tc.requested, requested, "the request is only read") + require.Equal(t, tc.parent, parent, "the parent is only read") + if tc.refusal != "" { + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.ErrorContains(t, err, tc.refusal) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.Len(t, got, len(tc.want)) + for i := range got { + require.True(t, proto.Equal(tc.want[i], got[i]), "scope %d: got %v", i, got[i]) + } + require.True(t, workcontext.ScopesAttenuate(got, parent)) + }) + } +} + +// The inherited scopes mint: an operation child under an owner-scoped parent +// carries the owner and verifies, where the operation's own owner-less scopes +// are refused as a widening. +func TestInheritResourceOwnerMintsUnderAnOwnerScopedParent(t *testing.T) { + h := newHarness(t) + token, _, err := h.authority.Start(context.Background(), workcontext.StartInput{ + InstallationID: installation, TenantID: tenant, OwnerPrincipalID: ownerID, OwnerPrincipalKind: "human", + OrganizationID: organization, TaskID: taskID, Audience: audience, TTL: time.Hour, + AuthorityScopes: []*basev0.WorkScopeV1{owned("doc", []string{"read", "write"}, nil, "principal-u")}, + }) + require.NoError(t, err) + parent := h.mustVerify(audience, token) + operation := []*basev0.WorkScopeV1{scope("doc", []string{"read"}, nil)} + mint := func(scopes []*basev0.WorkScopeV1) (string, error) { + child, _, 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 child, err + } + _, err = mint(operation) + require.ErrorIs(t, err, workcontext.ErrInvalid, "the operation's owner-less scope widens an owner-scoped parent") + + inherited, err := workcontext.InheritResourceOwner(operation, parent.EffectiveScopes()) + require.NoError(t, err) + child, err := mint(inherited) + require.NoError(t, err) + verified := h.mustVerify(audience, child) + require.Equal(t, "principal-u", verified.EffectiveScopes()[0].GetResourceOwnerId()) +} + +func cloneScopes(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { + cloned := make([]*basev0.WorkScopeV1, len(scopes)) + for i, s := range scopes { + cloned[i] = proto.CloneOf(s) } + return cloned } diff --git a/workcontext/scope.go b/workcontext/scope.go index 04badadb..f807e195 100644 --- a/workcontext/scope.go +++ b/workcontext/scope.go @@ -1,8 +1,11 @@ package workcontext import ( + "fmt" "slices" + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" ) @@ -13,7 +16,13 @@ import ( // 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. +// covered by an owner-selected row, so an owner-blind check BUILT ON THIS +// FUNCTION fails closed. A reader that inspects WorkScopeV1 fields itself — +// flattening a scope with no ids to "kind:action", or copying kind, actions +// and ids into its own type — gets no such guarantee: it reads an +// owner-selected scope as the whole kind. Such a reader must refuse, or grant +// nothing from, a scope with a non-empty resource_owner_id until it compares +// owners (docs/work-context.md lists the known ones). 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. @@ -68,3 +77,53 @@ func ScopesAttenuate(child, parents []*basev0.WorkScopeV1) bool { } return true } + +// InheritResourceOwner returns the scopes to mint an operation child with +// under a parent capability holding parent: the operation's own authority +// (requested, which never names an owner) narrowed to the owner the parent +// selects. It is how a runtime mints under an owner-scoped parent without +// writing an owner into any declaration, binding or selection. +// +// Each requested scope the parent covers as written is kept as written. One +// the parent covers only once an owner is named gets that owner, when exactly +// one of the owners the parent selects for its kind covers it. It fails +// closed, with ErrInvalid naming the reason, when no owner covers the scope +// (the parent does not hold it), when several do (the runtime must mint one +// child per owner rather than have one chosen for it), and when a requested +// scope already names an owner the parent does not cover it under — an owner +// someone wrote is never replaced. The result is a fresh copy; requested and +// parent are only read, and the result attenuates parent. +func InheritResourceOwner(requested, parent []*basev0.WorkScopeV1) ([]*basev0.WorkScopeV1, error) { + inherited := make([]*basev0.WorkScopeV1, 0, len(requested)) + for _, scope := range requested { + child := proto.CloneOf(scope) + if ScopeContained(child, parent) { + inherited = append(inherited, child) + continue + } + if owner := child.GetResourceOwnerId(); owner != "" { + return nil, fmt.Errorf("%w: scope %q names resource owner %q, which the parent does not cover it under", ErrInvalid, child.GetResourceKind(), owner) + } + var covering []string + for _, row := range parent { + owner := row.GetResourceOwnerId() + if owner == "" || row.GetResourceKind() != child.GetResourceKind() || slices.Contains(covering, owner) { + continue + } + child.ResourceOwnerId = owner + if ScopeContained(child, parent) { + covering = append(covering, owner) + } + } + switch len(covering) { + case 0: + return nil, fmt.Errorf("%w: scope %q is not covered by the parent, with or without a resource owner", ErrInvalid, child.GetResourceKind()) + case 1: + child.ResourceOwnerId = covering[0] + inherited = append(inherited, child) + default: + return nil, fmt.Errorf("%w: owner-scoped parent: scope %q is covered under resource owners %q, so the owner is ambiguous; mint one child per owner", ErrInvalid, child.GetResourceKind(), covering) + } + } + return inherited, nil +}