Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions docs/work-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 —
Expand All @@ -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.
Expand Down
13 changes: 7 additions & 6 deletions generated/go/codefly/base/v0/work_context.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading