Repository navigation
Policy: let a grant on a declared operation admit a caller who isn't signed in - #6579
Conversation
…signed in A declared operation is one invocation of its base, run with no actor, and a write it makes is made as the user the grant's actingUser names. A named query stays closed. A declared operation that reads actor() is warned about and opens nothing to such callers. The capability check's write lane asks whether the policy opens any write, base or declared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…writes-as-a-user-the-realms-config' into cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous
…dmits declared operations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Independent review
I reviewed the diff against the stacked base and the paired catalog#808. I found no blocker or major issue. I checked the widened admission against each path the brief listed:
- Named queries stay closed.
queryis reserved from declaration (RESERVED_BY_SEARCH, and lowering refuses it too), sogranted.base === 'query' && operation !== 'query'catches every named query. - Other excluded bases.
explain/validateare not inANONYMOUS_ELIGIBLE_OPERATIONS.readSourcecan't be declared.nonGrantableand policy-type grants are dropped before the anonymous branch. - The card verbs (
resolveFacadeWritewithbuiltInOnly) still refuse a declared name for an ACL-declined caller, so a grant on a declareddelete/updatedoesn't reach the HTTP verb. - A declared write with no acting user can't admit. The gate's
withActingUsersjudges by the resolved base, so a subtype that rebinds a name onto a write still admits nothing without a key. - The envelope's
needsActorcheck still refuses any resolved definition that readsactor(). - Old empty-array dispatch. No other consumer of the old empty-array dispatch or of
ReadonlySetfromgetAnonymousAdmission()exists outside the files changed here. - The catalog pin equals catalog#808's head. catalog#808's comment is accurate.
Findings, all minor or nit:
- minor
packages/runtime-common/card-operations/capabilities.ts:241(withcard-operations/policy.ts:1285): theanonymous-grant-reads-actorcheck reads only the rule'stargetTypedefinition. A subtype that overrides the declared operation with one that readsactor()keeps the parent grant's opt-in.- Scenario:
Petition.signdoesn't readactor().LocalPetition extends Petitionredeclaressignwithset: { signature: actor() }. A grant onPetitionwithanonymous: truematchesLocalPetitioninstances too. - Safe at invocation: the envelope refuses it with 401
actor-requiredvianeedsActor. - The bug: a capability check answers
allowed: truefor that pair, and so doesexplainwithactor: ''. The UI then shows a control that always 401s. - Not new: it existed for a specialized base op (a declared
updatewith an actor-readinginput). This PR makes it much easier to hit. - Suggested fix: in
decide, for an anonymous caller (!scope.caller.actor/ caller kindanonymous), returnrefused('actor-required')whenneedsActor(definition). Do the same in explain. Add a subtype case to the new test file.
- Scenario:
- nit
packages/runtime-common/card-operations/policy.ts:395:writesis re-derived by operation name, not by the grant that put the name in the summary.- Scenario: a write grant with an
actingUserwhosewherereadsactor()is dead for anonymous callers and left out of the summary. Another type's grant opens a read-based declared operation with the same name. That name then lands inwrites. - Effect: on a world-readable realm the capability check's write lane is handed to the gate instead of refused up front with
actor-required. The per-pair answers are still correct (the gate refuses), so the only effect is the lane-level answer and some wasted gate work. - Cost: this loop also walks every rule and grant on every anonymous request.
- Suggested fix: compute
writesinanonymousSummary(policy.ts:1726) alongsideoperations, from the same grants that pass its exclusions (grant.anonymous?.actingUserKey && !grant.where?.readsActor). Store it onCompiledRealmPolicy.anonymous, and makeanonymousAdmission()only wrap the stored arrays.
- Scenario: a write grant with an
- nit
packages/runtime-common/anonymous-admission.ts:58andpackages/runtime-common/realm.ts:2397: both comments still say the operations list is "(empty means any at all)". After this change an empty array admits nothing ([].findgivesundefined), and "any" is spelled'any'. The new comment inside the interface and the one inrouter.tsare right. These two now contradict them. Suggested fix: replace the parenthetical with "(anyfor any at all,any-writefor any write)".
Tests: I checked that the new and changed assertions can fail.
- The world-readable board case (
[true, false]) needs theany-writelane: withoutwritescontainingsign, the lane refusessignup front. - The compile test's index shift
grants[3]→grants[4]correctly follows the newly keptappendActivitygrant. writesis asserted directly.
I found no non-evergreen comments (no ticket IDs, no PR numbers, no journey wording).
… caller, and record which opened operations write The gate keeps no visitor grant for an operation whose resolved definition reads actor(), so capability checks and explain agree with the refusal the invocation gets. The compiled summary records the opened writes from the same grants as the opened operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
[Claude Code 🤖] Fixed in
This PR now gets its own separate review with inline comments. |
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Independent review of this PR
I reviewed this PR's own diff at 105c785e35, statically. I found no blocker or major issue. Each fix from the earlier combined review is correct and complete:
- The gate change (
decide) sits inside thescope.caller.kind === 'anonymous'branch, so signed-in callers andunattributedscopes are unaffected. It reads the target's resolved definition, so it covers a subtype's redeclaration. It uses the samereadsActorflag as the envelope'sneedsActor. It runs beforewithActingUsers, so it doesn't load acting users for nothing. - The subtype test can fail. Without the gate change, the capability check answers
[true, true]and explain answersallowed. The invocation's 401 alone would not catch it, becauseneedsActoralready refused that. writesis derived inanonymousSummaryfrom the same grants asoperations(with thewhere.readsActorand filterless-queryexclusions).actingUserKeyis only ever set for a write base. A summary withoutwriteswould givenew Set(undefined), which is empty, so it fails closed.- Consumers. Every consumer of
AnonymousOpenings, the'any' | 'any-write'dispatch and the summary's shape is updated:anonymous-admission.ts,realm.ts:2898/16015,router.ts,explain.ts(which reads onlyoperations), and the compile test. I found no other consumer ofANONYMOUS_ELIGIBLE_OPERATIONS.filter(isWrite)or of the old empty-array dispatch.
Paths I checked for anonymous widening, all closed:
- Named queries. Closed by
granted.base === 'query' && operation !== 'query', and the gate refusesqueryanyway. explain/validate,nonGrantableand policy types. These are refused before the anonymous branch, at compile and at the gate.- A subtype that rebinds an opened name. Onto
query/explain/validate, the gate refuses it. Onto a write, a grant withoutactingUserKeyadmits nothing. Onto an actor-reading op, the new check refuses it. - Declarations named after a base op (for example
deleterebound ontotransform). These now compile with an opt-in. That puts the name inoperations, which admits the card-verb route at the realm level. ButresolveFacadeWritewithbuiltInOnlyrefuses any declared name for an ACL-declined caller, and a type that matches the grant inherits the declaration, so the verb can't hard-delete. - Batches. Each entry is gated on its own.
readSource/file targets. These can't be declared.
Line-less finding:
- minor, PR description. It says a declared operation "needs nothing new at runtime: the gate already matches grants…". Since
105c785e35, the gate does change: it keeps no visitor grant for an operation whose resolved definition readsactor(). The compiled summary also gainswrites. The test list also omits the subtype case. Suggested fix: add a bullet for the gate change and the subtype test, and say thatanonymous.writesis recorded on the compiled policy.
I found no ticket IDs, PR numbers or journey wording in code comments.
| if (scope.caller.kind === 'anonymous') { | ||
| matched = matched.filter(({ grant }) => grant.anonymous); | ||
| matched = definition.readsActor | ||
| ? [] | ||
| : matched.filter(({ grant }) => grant.anonymous); |
There was a problem hiding this comment.
[Claude Code 🤖] minor. This refusal is recorded as no-grant, so an explain and the decision telemetry give the wrong reason.
- Scenario: a policy author's grant on
Petition.signhasanonymous: true. A visitor on aLocalPetition(whosesignreadsactor()) is refused. - What explain shows:
explainwithactor: ''answersdecision: 'denied', reason: 'no-grant'. Itsruleslist the very grant that opts in to visitors, with outcomenot-evaluated. ThePolicyDecisionEventalso saysno-grant. Meanwhile the real request gets 401actor-required. - Why it matters: compile can't warn here, because it only reads the rule's own type. So explain is the only tool the author has, and it points away from the cause.
- Suggested fix: refuse with a distinct reason. For example, add a
GateTraceRefusalsuch as'reads-actor'(and the matchingPolicyDecisionReason), callrefuse('reads-actor')here instead of emptyingmatched, and map it to the explain reasonactor-required. The subtype test can then assertreasonas well asdecision.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in a413e470ed and db42f0a2e9. The gate records this refusal as its own reads-actor (GateTraceRefusal and PolicyDecisionReason), and explain answers it with its own reason reads-actor. The policy panel says "This operation depends on who is asking, and someone who isn't signed in can't be identified, so a grant that opens it to them doesn't apply." (boxel-catalog#808). The subtype test asserts the reason, and the diagnosis skill lists reads-actor among the decision reasons.
| // What a route would run for a caller who isn't signed in: the operations, any | ||
| // one of which the realm's policy has to open to such callers for it to admit | ||
| // one there (empty means any at all), and how it counts against the realm's | ||
| // one there (`any` for any at all, `any-write` for any write), and how it counts against the realm's |
There was a problem hiding this comment.
[Claude Code 🤖] nit. This line runs to 102 columns. Prettier doesn't reflow comments, so it stays that way. The interface's member comment just below also says the same thing in more detail, so this parenthetical is duplicated. The same overlong line is at realm.ts:2399. Suggested fix: rewrap both to 80 columns, and drop the parenthetical here in favour of the member comment.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in a413e470ed. The block comment above AnonymousDispatch no longer repeats the member comment, and both rewritten comments wrap within 80 columns.
| // What the route would run for a caller who isn't signed in, where a grant | ||
| // could admit one: the operations any one of which the realm's policy has to | ||
| // open to such callers (empty for any at all), and how the request counts | ||
| // open to such callers (`any` for any at all), and how the request counts |
There was a problem hiding this comment.
[Claude Code 🤖] nit. This names only any, but AnonymousRouteOptions.operations below also takes 'any-write'. A route author reading this comment wouldn't know that the write lane exists. Suggested fix: "(any for any at all, any-write for any write)", matching anonymous-admission.ts and realm.ts.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in a413e470ed. The comment names both any and any-write.
| assert.deepEqual( | ||
| policy?.anonymous, | ||
| { operations: ['register', 'sign'], writes: ['register', 'sign'] }, | ||
| 'it opens nothing to such callers', |
There was a problem hiding this comment.
[Claude Code 🤖] nit. The assertion message says "it opens nothing to such callers", but the expected value opens register and sign. What is being checked is that claim is left out. If this fails, the message will mislead whoever reads it. Suggested fix: "claim is not among what it opens to such callers".
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in a413e470ed. The message now says claim is left out of what the policy opens to such callers.
…actor, and explain it as actor-required Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… own reason, and pin the panel that says what an empty person field asks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alms-config' into cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 1h 33m 21s ⏱️ - 17m 45s Results for commit f1c93e1. ± Comparison against earlier commit 51a78e0. Realm Server Test Results 1 files ±0 318 suites ±0 2h 8m 43s ⏱️ + 20m 43s Results for commit f1c93e1. ± Comparison against earlier commit 51a78e0. |
|
[Claude Code 🤖] On the review's description finding: the description now covers the gate's |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alms-config' into cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alms-config' into cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous # Conflicts: # packages/catalog/test-subset.json
…alms-config' into cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alms-config' into cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous # Conflicts: # packages/catalog/test-subset.json
Important
Merge order (each must merge before the next):
Compile a policy grant's opt-in to callers who aren't signed in #6534: compile a grant's anonymous opt-in (merged)Policy: admit anonymous reads through a grant that opts in, rate-limited per realm and address #6568(CS-13536): anonymous reads (merged)4a. This PR (CS-13605): anonymous named operations, stacked on Policy: admit anonymous writes as a user the realm's config names #6577. boxel-skills#174's §12 describes it
4b. Policy: explain how a realm treats a caller who isn't signed in #6581 (CS-13609): explaining a visitor's refusal, stacked on Policy: let a grant on a declared operation admit a caller who isn't signed in #6579. boxel-skills#174's §10/§12 describe it
Add the judgment calls to the realm-policy-authoring skill boxel-skills#175(CS-13108): merged, and merged into boxel-skills#174. This section is §12, Refusals §13, its section §14 and the checklist §15.5a.
Say that no policy grant reaches a path the realm ignores boxel-skills#178(CS-13336): merged, and merged into boxel-skills#1747a. Say that a policy's rules are not secret from the writers of other realms boxel-skills#181 (CS-13398): a realm-policy-authoring §9 bullet, stacked on z-index handling for modals opening modals #179, merges right after it
Every PR in this order carries this callout. Whoever opens one of the PRs above tells the owners of the others, and each owner updates its own callout.
Catalog: this PR pins cardstack/boxel-catalog#808, which changes only the comment on
OperationGrant.anonymous. The order there is boxel-catalog#805, then #6570, then boxel-catalog#806, then boxel-catalog#808, then this PR. As #805 and #806 merge, #808 takes catalogmainand this PR's pin moves to #808's new head.Merges after: cardstack/boxel-catalog#808
In service of CS-13521, which lets a realm's policy admit a caller who isn't signed in. Reads, searches and writes are open to such callers through grants on base operations. This PR lets a grant on an operation a type declares opt in too, so a public form can offer a narrow, declared write ("sign this petition", "register for updates") rather than a base
updateortransform.A declared operation is exactly one invocation of its base, with the author's params, template and program. The gate already matches grants by the name invoked, keeps only the grants that opt in, and judges a write's acting user by its base. For such a caller,
actor()stays empty inside the operation, and the write is made as the user the grant'sactingUsernames in the governed realm'srealm.jsonconfig, exactly as a base write is. It is counted one unit per invocation, like any batch entry. It is reached only through/_operations, since the card verbs never run a declaration.What changes:
card-operations/policy.ts).anonymous: trueis accepted on any operation whose base is eligible, under its own name or a declared one. A named query stays closed and still recordsanonymous-not-base-operation, since it runs a stored query on the search lane where only a grant's filter scopes it. The code keeps its name, which policies and the authoring skill already show.actor(). A grant that opens a declared operation whose program, template or output readsactor()records the warninganonymous-grant-reads-actoron itsoperation. It applies to signed-in callers only and opens nothing to such callers, who would otherwise be refused with 401actor-requiredon every invocation.actor(). A subtype can redeclare an opened operation so it readsactor(), which compile can't see from the grant's type. For such a caller, the gate refuses an operation whose resolved definition readsactor()with the refusalreads-actor, which explain reports as its own reason and the policy panel explains in words, so capability checks and explain agree with the 401 the invocation gets.anonymoussummary recordswritesbesideoperations, from the same grants.RealmPolicyCache.anonymousAdmission()returns them asAnonymousOpenings, and a route's anonymous dispatch namesanyorany-writeinstead of an empty list.realm-policy-anonymous-named-operation-test.tscovers:actor();actor(), refused, and answered so by capability checks and explain;The compile test moves to the new eligibility. The anonymous write, search and capabilities tests run unchanged.
🤖 Generated with Claude Code