From 45ebe69e5baea8ade312cfe7f51d9cc6a152f7bb Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:23:42 -0400 Subject: [PATCH] fix(workcontext): resource owner is an ASCII allowlist; owner-blind readers listed; joint-owner refusal named (r128) resource_owner_id is now ^[!-)+-~]*$ under max_bytes 512: printable ASCII other than space and "*". The category-exclusion pattern let invisible and lookalike characters through (U+034F, U+FE0F, U+3164, U+115F, U+2800, NFD accents, Cyrillic and fullwidth lookalikes), and Go and Python protovalidate disagreed on unassigned code points and noncharacters. The vectors live in workcontext/testdata/resource_owner_vectors.json; Go protovalidate and Python protovalidate 2.1.0 agree on all 40. InheritResourceOwner names the reason when only several owners' rows together cover a scope ("covered only across resource owners ... split it into one child per owner") instead of "not covered", and documents and tests that an inherited owner applies to the whole scope: under [doc:read, doc:write@P], doc:{read,write} becomes doc:{read,write}@P, an intended fail-closed confinement. docs/work-context.md lists the owner-blind readers r128 found (the authority gateway issuer, module-document-store, module-runtime, saas-sdk-python, the saas-starter accounts adapters) and says exactly what authority #18 (open) changes: the local issuer refuses owner-naming issue and delegation requests by name; the gateway issuer stays owner-blind. Co-Authored-By: Claude Opus 5.5 --- docs/work-context.md | 44 ++++- .../go/codefly/base/v0/work_context.pb.go | 13 +- .../codefly/base/v0/work_context_pb2.py | 22 +-- proto/codefly/base/v0/work_context.proto | 9 +- workcontext/owner_test.go | 96 ++++++---- workcontext/scope.go | 58 +++++- .../testdata/resource_owner_vectors.json | 171 ++++++++++++++++++ 7 files changed, 344 insertions(+), 69 deletions(-) create mode 100644 workcontext/testdata/resource_owner_vectors.json 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-*" + } + ] +}