Skip to content

Policy: let a grant on a declared operation admit a caller who isn't signed in - #6579

Merged
habdelra merged 14 commits into
mainfrom
cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous
Oct 6, 2026
Merged

habdelra merged 14 commits into
mainfrom
cs-13605-policy-let-a-grant-on-a-named-operation-admit-an-anonymous

Conversation

@habdelra

@habdelra habdelra commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Important

Merge order (each must merge before the next):

  1. Compile a policy grant's opt-in to callers who aren't signed in #6534: compile a grant's anonymous opt-in (merged)
  2. Policy: admit anonymous reads through a grant that opts in, rate-limited per realm and address #6568 (CS-13536): anonymous reads (merged)
  3. Policy: admit anonymous searches through a query grant that opts in #6575 (CS-13578): anonymous search, stacked on Policy: admit anonymous reads through a grant that opts in, rate-limited per realm and address #6568
  4. Policy: admit anonymous writes as a user the realm's config names #6577 (CS-13579): anonymous writes, stacked on Policy: admit anonymous searches through a query grant that opts in #6575
    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
  5. 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#174
  6. Teach the realm-policy-authoring skill anonymous grants and a realm's anonymous traffic controls boxel-skills#174 (CS-13571): the authoring skill for anonymous grants; re-checked against what shipped
  7. State every limit of a realm policy in the skills boxel-skills#179 (CS-13109): states every policy limit in realm-policy-authoring and card-operations-authoring; stacked on boxel-skills#174, merges after it
    7a. 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
  8. CS-13572 (CS-13571): once every open skills PR has merged, one boxel-skills release (v0.1.10) covering boxel-skills#175 and boxel-skills#177 (both merged), Add deployment for host package #178, Refactor indexer to run wholly inside the host app #174, z-index handling for modals opening modals #179 and Refactor Realm's handle method to accept a response object #181, then one plain-title boxel PR moving the pin to 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 catalog main and 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 update or transform.

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's actingUser names in the governed realm's realm.json config, 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:

  • Compiling (card-operations/policy.ts). anonymous: true is accepted on any operation whose base is eligible, under its own name or a declared one. A named query stays closed and still records anonymous-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.
  • An operation that reads actor(). A grant that opens a declared operation whose program, template or output reads actor() records the warning anonymous-grant-reads-actor on its operation. It applies to signed-in callers only and opens nothing to such callers, who would otherwise be refused with 401 actor-required on every invocation.
  • The gate, for an operation that reads actor(). A subtype can redeclare an opened operation so it reads actor(), which compile can't see from the grant's type. For such a caller, the gate refuses an operation whose resolved definition reads actor() with the refusal reads-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.
  • Capability checks on a realm anyone may read. The write lane asked whether the policy opens a base write. It now asks whether it opens any write, base or declared. The compiled policy's anonymous summary records writes beside operations, from the same grants. RealmPolicyCache.anonymousAdmission() returns them as AnonymousOpenings, and a route's anonymous dispatch names any or any-write instead of an empty list.

realm-policy-anonymous-named-operation-test.ts covers:

  • a visitor signing an open petition as the submitter;
  • identical 401s for a closed and a missing petition;
  • a grant on a declared write opening neither the base it is built on nor an operation granted only to signed-in callers;
  • a declared create that mints its id and fills its template;
  • the warning, and the refusal, for a declared operation that reads actor();
  • a subtype redeclaring an opened operation to read actor(), refused, and answered so by capability checks and explain;
  • one unit per invocation and the 429;
  • capability checks, including the world-readable write lane;
  • explain.

The compile test moves to the new eligibility. The anonymous write, search and capabilities tests run unchanged.

🤖 Generated with Claude Code

habdelra and others added 3 commits October 6, 2026 15:33
…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T19:44:55.420715Z 722ea4e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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. query is reserved from declaration (RESERVED_BY_SEARCH, and lowering refuses it too), so granted.base === 'query' && operation !== 'query' catches every named query.
  • Other excluded bases. explain/validate are not in ANONYMOUS_ELIGIBLE_OPERATIONS. readSource can't be declared. nonGrantable and policy-type grants are dropped before the anonymous branch.
  • The card verbs (resolveFacadeWrite with builtInOnly) still refuse a declared name for an ACL-declined caller, so a grant on a declared delete/update doesn't reach the HTTP verb.
  • A declared write with no acting user can't admit. The gate's withActingUsers judges by the resolved base, so a subtype that rebinds a name onto a write still admits nothing without a key.
  • The envelope's needsActor check still refuses any resolved definition that reads actor().
  • Old empty-array dispatch. No other consumer of the old empty-array dispatch or of ReadonlySet from getAnonymousAdmission() 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:

  1. minor packages/runtime-common/card-operations/capabilities.ts:241 (with card-operations/policy.ts:1285): the anonymous-grant-reads-actor check reads only the rule's targetType definition. A subtype that overrides the declared operation with one that reads actor() keeps the parent grant's opt-in.
    • Scenario: Petition.sign doesn't read actor(). LocalPetition extends Petition redeclares sign with set: { signature: actor() }. A grant on Petition with anonymous: true matches LocalPetition instances too.
    • Safe at invocation: the envelope refuses it with 401 actor-required via needsActor.
    • The bug: a capability check answers allowed: true for that pair, and so does explain with actor: ''. The UI then shows a control that always 401s.
    • Not new: it existed for a specialized base op (a declared update with an actor-reading input). This PR makes it much easier to hit.
    • Suggested fix: in decide, for an anonymous caller (!scope.caller.actor / caller kind anonymous), return refused('actor-required') when needsActor(definition). Do the same in explain. Add a subtype case to the new test file.
  2. nit packages/runtime-common/card-operations/policy.ts:395: writes is re-derived by operation name, not by the grant that put the name in the summary.
    • Scenario: a write grant with an actingUser whose where reads actor() 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 in writes.
    • 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 writes in anonymousSummary (policy.ts:1726) alongside operations, from the same grants that pass its exclusions (grant.anonymous?.actingUserKey && !grant.where?.readsActor). Store it on CompiledRealmPolicy.anonymous, and make anonymousAdmission() only wrap the stored arrays.
  3. nit packages/runtime-common/anonymous-admission.ts:58 and packages/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 ([].find gives undefined), and "any" is spelled 'any'. The new comment inside the interface and the one in router.ts are right. These two now contradict them. Suggested fix: replace the parenthetical with "(any for any at all, any-write for any write)".

Tests: I checked that the new and changed assertions can fail.

  • The world-readable board case ([true, false]) needs the any-write lane: without writes containing sign, the lane refuses sign up front.
  • The compile test's index shift grants[3] → grants[4] correctly follows the newly kept appendActivity grant.
  • writes is 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>
@habdelra

habdelra commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Fixed in 105c785e35, all three findings of the combined review above:

  1. The gate keeps no visitor grant for an operation whose resolved definition reads actor(), including a subtype's redeclaration. Capability checks and explain now answer denied, matching the 401 the invocation gets. A new test covers the subtype case.
  2. The compiled summary records writes from the same grants as operations, and anonymousAdmission() only wraps the stored arrays.
  3. Both stale comments now name any and any-write.

This PR now gets its own separate review with inline comments.

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 the scope.caller.kind === 'anonymous' branch, so signed-in callers and unattributed scopes are unaffected. It reads the target's resolved definition, so it covers a subtype's redeclaration. It uses the same readsActor flag as the envelope's needsActor. It runs before withActingUsers, 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 answers allowed. The invocation's 401 alone would not catch it, because needsActor already refused that.
  • writes is derived in anonymousSummary from the same grants as operations (with the where.readsActor and filterless-query exclusions). actingUserKey is only ever set for a write base. A summary without writes would give new 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 only operations), and the compile test. I found no other consumer of ANONYMOUS_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 refuses query anyway.
  • explain/validate, nonGrantable and 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 without actingUserKey admits nothing. Onto an actor-reading op, the new check refuses it.
  • Declarations named after a base op (for example delete rebound onto transform). These now compile with an opt-in. That puts the name in operations, which admits the card-verb route at the realm level. But resolveFacadeWrite with builtInOnly refuses 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 reads actor(). The compiled summary also gains writes. The test list also omits the subtype case. Suggested fix: add a bullet for the gate change and the subtype test, and say that anonymous.writes is recorded on the compiled policy.

I found no ticket IDs, PR numbers or journey wording in code comments.

Comment on lines +657 to +660
if (scope.caller.kind === 'anonymous') {
matched = matched.filter(({ grant }) => grant.anonymous);
matched = definition.readsActor
? []
: matched.filter(({ grant }) => grant.anonymous);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.sign has anonymous: true. A visitor on a LocalPetition (whose sign reads actor()) is refused.
  • What explain shows: explain with actor: '' answers decision: 'denied', reason: 'no-grant'. Its rules list the very grant that opts in to visitors, with outcome not-evaluated. The PolicyDecisionEvent also says no-grant. Meanwhile the real request gets 401 actor-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 GateTraceRefusal such as 'reads-actor' (and the matching PolicyDecisionReason), call refuse('reads-actor') here instead of emptying matched, and map it to the explain reason actor-required. The subtype test can then assert reason as well as decision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +56 to +58
// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in a413e470ed. The block comment above AnonymousDispatch no longer repeats the member comment, and both rewritten comments wrap within 80 columns.

Comment thread packages/runtime-common/router.ts Outdated
// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in a413e470ed. The message now says claim is left out of what the policy opens to such callers.

habdelra and others added 3 commits October 6, 2026 16:03
…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
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   1h 33m 21s ⏱️ - 17m 45s
5 177 tests ±0  5 166 ✅ ±0  11 💤 ±0  0 ❌ ±0 
5 192 runs  ±0  5 181 ✅ ±0  11 💤 ±0  0 ❌ ±0 

Results for commit f1c93e1. ± Comparison against earlier commit 51a78e0.

Realm Server Test Results

    1 files  ±0    318 suites  ±0   2h 8m 43s ⏱️ + 20m 43s
5 067 tests ±0  5 067 ✅ ±0  0 💤 ±0  0 ❌ ±0 
5 126 runs  ±0  5 126 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit f1c93e1. ± Comparison against earlier commit 51a78e0.

@habdelra

habdelra commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] On the review's description finding: the description now covers the gate's reads-actor refusal, the compiled summary's writes, and the subtype test.

@habdelra
habdelra requested a review from a team October 6, 2026 20:30
habdelra and others added 3 commits October 6, 2026 16:35
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
@habdelra
habdelra changed the base branch from cs-13579-policy-admit-anonymous-writes-as-a-user-the-realms-config to main October 6, 2026 23:18
@habdelra
habdelra merged commit 89e90ac into main Oct 6, 2026
94 of 99 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants