diff --git a/docs/work-context.md b/docs/work-context.md index f8f07426..283586bc 100644 --- a/docs/work-context.md +++ b/docs/work-context.md @@ -96,10 +96,15 @@ 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. 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. +printable ASCII other than space and `*` (`^[!-)+-~]*$`, 0x21–0x29 and +0x2B–0x7E), the schema's rule, which `protovalidate` enforces at mint and at +decode. It is an allowlist on purpose: a list of excluded Unicode categories +let through characters that print as nothing or as another letter (U+034F, +U+FE0F, U+3164, U+2800, NFD accents, Cyrillic lookalikes), and Go and Python +protovalidate disagreed on unassigned code points. With ASCII, two owners that +print alike are the same bytes and every regex engine gives one answer; +`workcontext/testdata/resource_owner_vectors.json` holds the vectors both are +checked against. Principal ids are UUIDs, which fit. 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 @@ -112,19 +117,31 @@ 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: +scopes directly, read at each repository's main on 2026-10-25 (reviews r109 +and r128). It is the list known so far, not a proof that no other exists: | 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 | +| authority hop library (Go) | `libraries/hop/go/hop.go` (`flatten`) | stamps `kind:action` for every scope without ids, so `Identity.Has` widens; authority #18 (open) skips owner-selected scopes | +| authority client (Python) | `authority_client/work_context.py` (`Scope`, `scope_contained`) | the `Scope` tuple drops the owner and containment ignores it; authority #18 (open) keeps the owner | +| authority hop (Python) | `codefly_hop` (`_flatten`) | same flattening as the Go hop; changed in authority #18 (open) | +| authority gateway issuer | `modules/authority/services/gateway/code/pkg/business/issuer/issuer.go:98-118`, `state.go:1156` | a held scope with no ids sets `whole`, which returns every grant (or the human owner's) for the kind | | 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 | +| module-document-store | `modules/documents/services/documents/code/pkg/business/workclaims/claims.go:191-199` | a matching scope with no resource ids returns allow | +| module-runtime | `pkg/runnable/installation.go:225`, `pkg/infra/accounts/authority.go:297` | copies kind, actions and ids into its own `Scope` / `WorkContextScope` and drops the owner | +| saas-sdk-python | `src/saas_sdk/work_context.py:158-163` (`WorkScope`), `:638-646` (`_scopes_of`), `:322-327` (`require_scope`) | `WorkScope` has no owner, `_scopes_of` drops it, and `require_scope` allows the whole kind. It refuses the unknown field today, so it widens once it regenerates from this core | +| module-saas-starter, module-saas-starter-security accounts | `module/services/accounts/code/pkg/adapters/` (`work_context_rpcs.go`, `delegated_read_audience.go`) | copy kind, actions and ids into their own types and drop the owner | | 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. +The authority's `Claims.Allows` builds on `ScopeContained` and fails closed, +but its issuers do not use it: the gateway issuer above reads scopes itself. +Authority #18 (open, not merged when this was written) makes the local issuer +refuse, by name, an issue request or a delegation whose scopes name a resource +owner ("this authority issues no owner-selected scope yet"). That refusal +covers what the local issuer is asked to mint; it does not make the gateway +issuer's resource-grant path owner-aware. 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 — @@ -135,7 +152,14 @@ 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 +child per owner), when only several owners' rows together cover it (split it +by owner; the refusal says so rather than "not covered"), or when the scope +already names another owner. An inherited owner applies to the whole scope: +under the parent `[doc:read, doc:write@P]` the operation scope +`doc:{read,write}` becomes `doc:{read,write}@P`, so the child reads only P's +documents although the parent reads all of them. That is intended and fails +closed — an operation invoked under an owner-scoped capability is confined to +that owner's resources — and `TestInheritResourceOwner` pins it. 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. diff --git a/generated/go/codefly/base/v0/work_context.pb.go b/generated/go/codefly/base/v0/work_context.pb.go index 54bef483..ef557771 100644 --- a/generated/go/codefly/base/v0/work_context.pb.go +++ b/generated/go/codefly/base/v0/work_context.pb.go @@ -546,9 +546,10 @@ type WorkScopeV1 struct { // 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. 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. + // of printable ASCII other than space and "*" (0x21-0x29, 0x2B-0x7E): no + // whitespace, control, non-ASCII, invisible or lookalike character, so two + // owners that print alike are the same bytes and every regex engine answers + // the rule the same way. ResourceOwnerId string `protobuf:"bytes,4,opt,name=resource_owner_id,json=resourceOwnerId,proto3" json:"resource_owner_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1041,13 +1042,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\"\xc6\x01\n" + + "\vincarnation\x18\x03 \x01(\x04B\a\xbaH\x042\x02(\x01R\vincarnation\"\xbe\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\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" + + "\fresource_ids\x18\x03 \x03(\tR\vresourceIds\x12A\n" + + "\x11resource_owner_id\x18\x04 \x01(\tB\x15\xbaH\x12r\x10(\x80\x042\v^[!-)+-~]*$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 0db89123..e8fc348e 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\"\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') +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\"\xbe\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\x41\n\x11resource_owner_id\x18\x04 \x01(\tB\x15\xbaH\x12r\x10(\x80\x04\x32\x0b^[!-)+-~]*$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\032r\030(\200\0042\023^[^\\s\\p{Z}\\p{C}*]*$' + _globals['_WORKSCOPEV1'].fields_by_name['resource_owner_id']._serialized_options = b'\272H\022r\020(\200\0042\013^[!-)+-~]*$' _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=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 + _globals['_WORKSCOPEV1']._serialized_end=2225 + _globals['_WORKACTORV1']._serialized_start=2228 + _globals['_WORKACTORV1']._serialized_end=2690 + _globals['_WORKGRANTHOPV1']._serialized_start=2693 + _globals['_WORKGRANTHOPV1']._serialized_end=2987 + _globals['_WORKAPPROVERV1']._serialized_start=2989 + _globals['_WORKAPPROVERV1']._serialized_end=3103 + _globals['_APPROVALREQUIREDV1']._serialized_start=3106 + _globals['_APPROVALREQUIREDV1']._serialized_end=3377 # @@protoc_insertion_point(module_scope) diff --git a/proto/codefly/base/v0/work_context.proto b/proto/codefly/base/v0/work_context.proto index 8482062a..7ce6d190 100644 --- a/proto/codefly/base/v0/work_context.proto +++ b/proto/codefly/base/v0/work_context.proto @@ -204,10 +204,11 @@ message WorkScopeV1 { // 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. 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}*]*$"]; + // of printable ASCII other than space and "*" (0x21-0x29, 0x2B-0x7E): no + // whitespace, control, non-ASCII, invisible or lookalike character, so two + // owners that print alike are the same bytes and every regex engine answers + // the rule the same way. + string resource_owner_id = 4 [(buf.validate.field).string.max_bytes = 512, (buf.validate.field).string.pattern = "^[!-)+-~]*$"]; } // WorkActorV1 is one verified delegated actor. granted_scopes are the actor's diff --git a/workcontext/owner_test.go b/workcontext/owner_test.go index 2f122a3a..f9cbfc25 100644 --- a/workcontext/owner_test.go +++ b/workcontext/owner_test.go @@ -2,6 +2,9 @@ package workcontext_test import ( "context" + "encoding/json" + "os" + "path/filepath" "strings" "testing" "time" @@ -109,11 +112,35 @@ func TestVerifyRefusesAHopThatDropsOrChangesTheOwner(t *testing.T) { } } -// 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. +// The owner is at most 512 bytes of printable ASCII other than space and "*". +// An allowlist rather than a list of excluded categories: Unicode keeps +// characters that print as nothing or as another letter outside any category +// a pattern can name, and engines disagree on the categories themselves (Go's +// unicode.C includes unassigned code points; Python protovalidate's does not). +// The vectors live in testdata so a second implementation's validator is +// checked against the same list. func TestResourceOwnerSchema(t *testing.T) { + type vector struct { + Name string `json:"name"` + Owner string `json:"owner"` + Repeat int `json:"repeat"` + } + var vectors struct { + Accepted []vector `json:"accepted"` + Refused []vector `json:"refused"` + } + data, err := os.ReadFile(filepath.Join("testdata", "resource_owner_vectors.json")) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &vectors)) + require.NotEmpty(t, vectors.Accepted) + require.NotEmpty(t, vectors.Refused) + owner := func(v vector) string { + if v.Repeat > 0 { + return strings.Repeat(v.Owner, v.Repeat) + } + return v.Owner + } + h := newHarness(t) start := func(owner string) error { _, _, err := h.authority.Start(context.Background(), workcontext.StartInput{ @@ -123,41 +150,14 @@ func TestResourceOwnerSchema(t *testing.T) { }) 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 _, v := range vectors.Accepted { + t.Run("accepts "+v.Name, func(t *testing.T) { + require.NoError(t, start(owner(v))) }) } - 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) + for _, v := range vectors.Refused { + t.Run("refuses "+v.Name, func(t *testing.T) { + err := start(owner(v)) require.ErrorIs(t, err, workcontext.ErrInvalid) require.Contains(t, err.Error(), "resource_owner_id") }) @@ -214,6 +214,28 @@ func TestInheritResourceOwner(t *testing.T) { parent: rows(owned("doc", []string{"read"}, nil, "u"), owned("doc", []string{"read"}, nil, "v")), refusal: "owner-scoped parent", }, + // Intended, and fail-closed: the owner applies to the whole scope, so + // the read the parent holds on every document is confined to P's. + "an owner applies to the whole scope, read included": { + requested: rows(scope("doc", []string{"read", "write"}, nil)), + parent: rows(scope("doc", []string{"read"}, nil), owned("doc", []string{"write"}, nil, "P")), + want: rows(owned("doc", []string{"read", "write"}, nil, "P")), + }, + "ids covered only by two owners together": { + requested: rows(scope("doc", []string{"read"}, []string{"a", "b"})), + parent: rows(owned("doc", []string{"read"}, []string{"a"}, "P"), owned("doc", []string{"read"}, []string{"b"}, "Q")), + refusal: `owner-scoped parent: scope "doc" is covered only across resource owners ["P" "Q"] together`, + }, + "actions covered only by two owners together": { + requested: rows(scope("doc", []string{"read", "write"}, nil)), + parent: rows(owned("doc", []string{"read"}, nil, "P"), owned("doc", []string{"write"}, nil, "Q")), + refusal: `covered only across resource owners ["P" "Q"] together`, + }, + "an id no owner covers beside ids two owners do": { + requested: rows(scope("doc", []string{"read"}, []string{"a", "c"})), + parent: rows(owned("doc", []string{"read"}, []string{"a"}, "P"), owned("doc", []string{"read"}, []string{"b"}, "Q")), + refusal: "not covered by the parent", + }, "an owner someone wrote is never replaced": { requested: rows(owned("doc", []string{"read"}, nil, "v")), parent: rows(owned("doc", []string{"read"}, nil, "u")), diff --git a/workcontext/scope.go b/workcontext/scope.go index f807e195..ac31f09d 100644 --- a/workcontext/scope.go +++ b/workcontext/scope.go @@ -89,10 +89,20 @@ func ScopesAttenuate(child, parents []*basev0.WorkScopeV1) bool { // 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 +// child per owner rather than have one chosen for it), when only several +// owners' rows together cover it (split it by owner), 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. +// +// An owner applies to the whole scope it is inherited onto. Under the parent +// [doc:read, doc:write@P], the operation scope doc:{read,write} becomes +// doc:{read,write}@P: the child reads only P's documents, although the parent +// reads every document. That is intended and fails closed. An operation +// invoked under an owner-scoped capability is confined to that owner's +// resources, and splitting the scope by action to keep the wider read would +// mint a child the operation's single declared scope for the kind never +// described. func InheritResourceOwner(requested, parent []*basev0.WorkScopeV1) ([]*basev0.WorkScopeV1, error) { inherited := make([]*basev0.WorkScopeV1, 0, len(requested)) for _, scope := range requested { @@ -117,6 +127,10 @@ func InheritResourceOwner(requested, parent []*basev0.WorkScopeV1) ([]*basev0.Wo } switch len(covering) { case 0: + child.ResourceOwnerId = "" + if owners := coveringOwners(child, parent); len(owners) > 1 { + return nil, fmt.Errorf("%w: owner-scoped parent: scope %q is covered only across resource owners %q together, never under one; split it into one child per owner", ErrInvalid, child.GetResourceKind(), owners) + } 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] @@ -127,3 +141,45 @@ func InheritResourceOwner(requested, parent []*basev0.WorkScopeV1) ([]*basev0.Wo } return inherited, nil } + +// coveringOwners reports, for a scope no single owner covers, the owners whose +// rows cover it together: every (action, resource) point of the scope is +// covered by the parent under one owner or another. It returns nil when some +// point is not covered at all, so "not covered" and "covered only across +// owners" are refused with their own reasons. +func coveringOwners(child *basev0.WorkScopeV1, parent []*basev0.WorkScopeV1) []string { + candidates := []string{""} + for _, row := range parent { + if owner := row.GetResourceOwnerId(); owner != "" && row.GetResourceKind() == child.GetResourceKind() && !slices.Contains(candidates, owner) { + candidates = append(candidates, owner) + } + } + ids := child.GetResourceIds() + if len(ids) == 0 { + ids = []string{""} + } + var owners []string + for _, action := range child.GetActions() { + for _, id := range ids { + point := &basev0.WorkScopeV1{ResourceKind: child.GetResourceKind(), Actions: []string{action}} + if id != "" { + point.ResourceIds = []string{id} + } + covered := false + for _, owner := range candidates { + point.ResourceOwnerId = owner + if ScopeContained(point, parent) { + covered = true + if owner != "" && !slices.Contains(owners, owner) { + owners = append(owners, owner) + } + break + } + } + if !covered { + return nil + } + } + } + return owners +} diff --git a/workcontext/testdata/resource_owner_vectors.json b/workcontext/testdata/resource_owner_vectors.json new file mode 100644 index 00000000..27e8e1c5 --- /dev/null +++ b/workcontext/testdata/resource_owner_vectors.json @@ -0,0 +1,171 @@ +{ + "_comment": "resource_owner_id vectors: every protovalidate implementation must accept the first list and refuse the second. Read by workcontext/owner_test.go.", + "accepted": [ + { + "name": "512 ASCII bytes", + "owner": "u", + "repeat": 512 + }, + { + "name": "a principal UUID", + "owner": "5f0c8a2e-3b1d-4c7a-9e2f-6a1b0c3d4e5f" + }, + { + "name": "a path-like principal", + "owner": "solutions/org-1/wiki:principal" + }, + { + "name": "every allowed punctuation", + "owner": "!\"#$%&'()+,-./:;<=>?@[\\]^_`{|}~" + } + ], + "refused": [ + { + "name": "513 ASCII bytes", + "owner": "u", + "repeat": 513 + }, + { + "name": "512 bytes of four-byte runes", + "owner": "\ud83d\ude00", + "repeat": 128 + }, + { + "name": "2048 bytes in 512 runes", + "owner": "\ud83d\ude00", + "repeat": 512 + }, + { + "name": "a leading space", + "owner": " principal-u" + }, + { + "name": "an inner space", + "owner": "principal u" + }, + { + "name": "a trailing newline", + "owner": "principal-u\n" + }, + { + "name": "a tab", + "owner": "principal\tu" + }, + { + "name": "a vertical tab", + "owner": "principal\u000bu" + }, + { + "name": "a form feed", + "owner": "principal\fu" + }, + { + "name": "a NUL", + "owner": "principal\u0000u" + }, + { + "name": "a start-of-heading control", + "owner": "principal\u0001u" + }, + { + "name": "DEL", + "owner": "principal\u007fu" + }, + { + "name": "a C1 control", + "owner": "principal\u0085u" + }, + { + "name": "a non-breaking space", + "owner": "principal-u\u00a0" + }, + { + "name": "an ideographic space", + "owner": "principal\u3000u" + }, + { + "name": "a line separator", + "owner": "principal\u2028u" + }, + { + "name": "a zero-width space", + "owner": "principal-u\u200b" + }, + { + "name": "a zero-width joiner", + "owner": "principal\u200du" + }, + { + "name": "a byte-order mark", + "owner": "\ufeffprincipal-u" + }, + { + "name": "a soft hyphen", + "owner": "principal\u00adu" + }, + { + "name": "a right-to-left override", + "owner": "principal\u202eu" + }, + { + "name": "a private-use character", + "owner": "principal\ue000u" + }, + { + "name": "a combining grapheme joiner", + "owner": "principal-u\u034f" + }, + { + "name": "variation selector-16", + "owner": "principal-u\ufe0f" + }, + { + "name": "a Hangul filler", + "owner": "principal\u3164u" + }, + { + "name": "a Hangul choseong filler", + "owner": "principal\u115fu" + }, + { + "name": "a Braille blank", + "owner": "principal\u2800u" + }, + { + "name": "an NFC accent", + "owner": "principal-\u00e9" + }, + { + "name": "an NFD accent", + "owner": "principal-e\u0301" + }, + { + "name": "a Cyrillic lookalike", + "owner": "\u0440rincipal-u" + }, + { + "name": "a fullwidth lookalike", + "owner": "\uff50rincipal-u" + }, + { + "name": "an unassigned code point", + "owner": "a\u0378b" + }, + { + "name": "the noncharacter U+FFFF", + "owner": "a\uffffb" + }, + { + "name": "the noncharacter U+FDD0", + "owner": "a\ufdd0b" + }, + { + "name": "the wildcard", + "owner": "*" + }, + { + "name": "a wildcard inside", + "owner": "principal-*" + } + ] +}