Skip to content

Open anonymous access through a grant's where clause and its own BXL expressions - #825

Merged
habdelra merged 13 commits into
mainfrom
cs-13651-replace-anonymous-grant-flag-acting-user-key-and-realmjson
Oct 9, 2026
Merged

habdelra merged 13 commits into
mainfrom
cs-13651-replace-anonymous-grant-flag-acting-user-key-and-realmjson

Conversation

@habdelra

@habdelra habdelra commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Important

Merge order: this PR, then cardstack/boxel#6622.
Merge this first. The boxel PR pins this PR's head and runs its tests against these fields; once this merges, re-run its Catalog Test Subset check and merge it.

Merges before: cardstack/boxel#6622

OperationGrant used to open itself to callers who aren't signed in with an anonymous boolean, and to name the user their writes were made as with a key into the governed realm's realm.json config. How hard such callers could hit the realm, and which addresses it refused, weren't on the grant at all — they were separate realm.json settings.

The grant now says all of it, in the same BXL the condition is written in. A caller who isn't signed in has actor() == "anonymous", so the condition itself is the opt-in, and the boolean is gone. Four expression fields sit beside it:

  • actingUser — who a visitor's write is made as. A full expression now, so realmConfig("submitter") keeps the old behaviour while "@writer:example.com", policy("writer") and instance().owner become possible.
  • blocklist — the addresses this grant refuses, as a comma-separated string or a list.
  • rateLimitRequests and rateLimitWindowSeconds — how many requests one address may make through this grant in a window. Either left empty is the platform's, which the realm reports in its info, so the edit form can name the actual number instead of saying "the default".

The one-line grant view gains the limit and the blocklist beside the acting user, and the edit form gains a field and a hint for each. The explanation panel reads the new per-grant detail: who a write resolves to or why it resolves to nobody, what the blocklist produced or why it closes the grant, and the limit with where each half came from. The realm-wide limit and blocklist lines are gone from the explanation header, replaced by the platform default a grant falls back to.

Every view of a policy card records the platform limit for its realm before its grants render, because a grant is a field and sees no realm info of its own.

🤖 Generated with Claude Code

Preview panels

Policy edit view:

Policy edit view

Realm Config edit view:

Realm Config edit view

habdelra and others added 4 commits October 8, 2026 09:39
Copilot AI balanced review requested due to automatic review settings October 9, 2026 02:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Staging Submissions Preview

This PR's content is pushed to the staging submissions realm: https://realms-staging.stack.cards/submissions/

Changed folders:

  • realm-policy/

Updated at 2026-10-09 14:57:21 UTC for commit f5a5de9. Shared realm: only this PR's changed files are pushed; files touched by multiple PRs reflect whichever pushed last, and deleted files are not removed.

@richardhjtan richardhjtan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code 🤖] One blocking finding, inline.

Comment thread realm-policy/realm-policy.gts Outdated

@richardhjtan richardhjtan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Claude Code 🤖] One blocking finding, inline.

Comment thread realm-policy/realm-policy.gts Outdated
@habdelra
habdelra requested review from a team and a lite review from Copilot October 9, 2026 12:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved compilation and rendering issues, plus incorrect fallback and compatibility behavior, must be addressed.

4 open findings

🧠 Review effort: Lite

Comment thread realm-policy/realm-policy.gts Outdated
// reads it in. `reads-actor` is named here as well as through the
// explanation type, so the map covers it whichever platform version this
// realm runs on.
const REASONS: Record<PolicyExplanation['reason'] | 'reads-actor', string> = {

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 abeacc61 and retained in 5c2ff14b. The map and its key type include blocklist-invalid for compatibility with both platform reason unions.

Comment thread realm-policy/realm-policy.gts Outdated
Comment on lines +94 to +95
let requests = grant?.rateLimitRequests ?? platform?.requests;
let windowSeconds = grant?.rateLimitWindowSeconds ?? platform?.windowSeconds;

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 5c2ff14b. Empty rate-limit fields fall back to the platform values before the inline limit is rendered.

Comment thread realm-policy/Spec/operation-grant.json Outdated
"summary": "An operation, an optional BXL condition, and whether callers who aren't signed in are admitted."
},
"readMe": "## What it is\n\nOne grant in a `PolicyRule`. It admits a caller to one operation on cards of the rule's type when its condition holds.\n\n```ts\nimport { OperationGrant } from '@cardstack/catalog/realm-policy/realm-policy';\n\n@field grants = containsMany(OperationGrant);\n```\n\n## Fields\n\n* **`operation`**: the operation name as a caller invokes it. That is a base operation such as `read`, `create`, `update`, `delete` or `query`, or the name of an operation a card declares. A grant on a named operation does not grant the base operation it is built on. A search runs as `query`, so a type callers should find in a search needs a `query` grant: a `read` grant opens a card by its URL only. A `query` grant's condition becomes a search filter, which can't express everything a condition can (it can't test a yes/no field, for one), and a `query` grant whose condition it can't express is inactive.\n* **`where`**: a BXL boolean expression over the target card and the caller (`actor()`), stored as written. Absent means the grant is unconditional. In a policy document it is a bare string of BXL.\n\n A condition checks the saved card, so it should read fields the card stores. A computed field, or a field of a linked card, exists only in the search index's copy of the card, so a condition that reads one compiles as inactive and grants nothing. To read the index's copy on purpose, accepting that it lags the card until the card is indexed again, write the condition as `{ \"bxl\": \"...\", \"snapshot\": true }`. A `create` can't use one, since the card it would mint isn't indexed yet.\n\n A write's condition is checked against the card as it was before the write. So it can't stop the write from changing the field it checks: a grant to `update` drafts lets its caller publish one in the same write.\n* **`anonymous`**: whether the grant also admits a caller who isn't signed in. Off, which is the default, it admits only signed-in callers.\n* **`actingUser`**: for an anonymous grant on a write, the key in the governed realm's `realm.json` `config` settings whose value is the Matrix user id the write is made as. That user must be able to write the realm. An anonymous grant on a write without it is left out of the policy, for signed-in callers too.\n\n## Callers who aren't signed in\n\n`anonymous` may be set on a grant for a base operation other than `explain` or `validate`, or for an operation a card declares on such a base. It may not be set on a grant for a named query: setting it there leaves the whole grant out, for signed-in callers too.\n\nA caller who isn't signed in has no actor. A grant whose `where` reads `actor()`, or whose operation does, never admits one, though it still applies to signed-in callers. So an anonymous grant is scoped by what the target holds, not by who is asking.\n\nThe realm, not the policy, names the user an anonymous write is made as. The policy can live in another realm, and its writers must not decide whose identity this realm's writes carry, so `actingUser` holds a key and the governed realm maps it to a user:\n\n```json\n\"attributes\": {\n \"policy\": \"https://app.example/org/policies/newsroom\",\n \"config\": { \"submitter\": \"@submitter:example.com\" }\n}\n```\n\n## The examples\n\n1. **Unconditional**: `read` with no condition, for every signed-in caller, including one the realm's own permissions don't let read it.\n2. **Conditioned on the card**: `update` while the card isn't `published`.\n3. **Conditioned on the caller**: `delete` when the caller is one of the card's `authorIds`.\n4. **Open to visitors**: `read` of `published` cards for callers who aren't signed in.\n5. **An anonymous write**: `create` by callers who aren't signed in, made as the user the realm's `submitter` setting names.\n\n## Non-goals\n\nA grant can't refuse anything, can't open any operation on a policy card or the realm's settings card, reads included, and can't open `explain` or `validate`, which no policy may grant.\n",
"readMe": "## What it is\n\nOne grant in a `PolicyRule`. It admits a caller to one operation on cards of the rule's type when its condition holds.\n\n```ts\nimport { OperationGrant } from '@cardstack/catalog/realm-policy/realm-policy';\n\n@field grants = containsMany(OperationGrant);\n```\n\n## Fields\n\n* **`operation`**: the operation name as a caller invokes it. That is a base operation such as `read`, `create`, `update`, `delete` or `query`, or the name of an operation a card declares. A grant on a named operation does not grant the base operation it is built on. A search runs as `query`, so a type callers should find in a search needs a `query` grant: a `read` grant opens a card by its URL only. A `query` grant's condition becomes a search filter, which can't express everything a condition can (it can't test a yes/no field, for one), and a `query` grant whose condition it can't express is inactive.\n* **`where`**: a BXL boolean expression over the target card and the caller (`actor()`), stored as written. It can also read the governed realm's settings (`realmConfig()`) and this policy card's fields (`policy()`). Absent means the grant is unconditional. In a policy document it is a bare string of BXL.\n\n A condition checks the saved card, so it should read fields the card stores. A computed field, or a field of a linked card, exists only in the search index's copy of the card, so a condition that reads one compiles as inactive and grants nothing. To read the index's copy on purpose, accepting that it lags the card until the card is indexed again, write the condition as `{ \"bxl\": \"...\", \"snapshot\": true }`. A `create` can't use one, since the card it would mint isn't indexed yet.\n\n A write's condition is checked against the card as it was before the write. So it can't stop the write from changing the field it checks: a grant to `update` drafts lets its caller publish one in the same write.\n* **`actingUser`**, **`blocklist`**, **`rateLimitRequests`**, **`rateLimitWindowSeconds`**: BXL expressions that say how the grant treats callers who aren't signed in (below). Each is used only for such a caller.\n\n## Callers who aren't signed in\n\nFor a caller who isn't signed in, `actor()` is `\"anonymous\"`. A grant admits one only when its `where` names that text, such as `actor() == \"anonymous\"`, or `actor() == \"anonymous\" and .published == true`. A grant whose `where` never names it admits only signed-in callers, even one whose condition would hold for anybody. So a grant written for signed-in callers never starts admitting everyone, and one meant for everyone is two grants: one naming `\"anonymous\"`, and one for signed-in callers.\n\nOnly a grant for a base operation other than `explain` or `validate`, or for an operation a card declares on such a base, can name `\"anonymous\"`. A grant for a named query that names it is left out, for signed-in callers too. An operation whose program reads `actor()` can't be run by a caller who isn't signed in, so such a grant never admits one.\n\nEach expression can read the governed realm's settings (`realmConfig(\"key\")`), this policy card's fields (`policy(\"field\")`), or be written out:\n\n* **`actingUser`**: for a grant on a write, the Matrix user a write by a caller who isn't signed in is made as, such as `realmConfig(\"submitter\")`, `policy(\"writer\")`, `\"@writer:example.com\"`, or a user the card names, `instance().owner`. That user must be able to write the realm. Without one, the grant admits no write by such a caller. A signed-in caller's writes are made as themselves, and reads never use it.\n* **`blocklist`**: the addresses the grant refuses, as a comma-separated string of IP addresses and CIDR ranges, such as `\"192.0.2.1, 10.0.0.0/8\"`, or a list of them. One that can't be read refuses every caller the grant would admit.\n* **`rateLimitRequests`** and **`rateLimitWindowSeconds`**: how many requests one address may make through the grant in a window, as whole numbers. Each one left out, or that produces no whole number, is the platform's default, which the grant's views show. Each grant counts its own requests.\n\nThe blocklist and rate limit can't read the card a request names (`instance()`). They are settled before the card is read, so a refused address gets the same answer whatever card it asks for, and costs the realm nothing more.\n\n## The examples\n\n1. **Unconditional**: `read` with no condition, for every signed-in caller, including one the realm's own permissions don't let read it.\n2. **Conditioned on the card**: `update` while the card isn't `published`.\n3. **Conditioned on the caller**: `delete` when the caller is one of the card's `authorIds`.\n4. **Open to visitors**: `read` of `published` cards for callers who aren't signed in, limited to the requests the realm's `visitorRequests` setting allows, and refusing the addresses its `blockedIps` setting lists.\n5. **An anonymous write**: `create` by callers who aren't signed in, made as the user the realm's `submitter` setting names.\n\n## Non-goals\n\nA grant can't refuse anything, can't open any operation on a policy card or the realm's settings card, reads included, and can't open `explain` or `validate`, which no policy may grant.\n",

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 5c2ff14b. The OperationGrant documentation distinguishes anonymous address blocking from refusing access the realm already permits.

Comment on lines +223 to +227
@field blocklist = contains(StringField);
// How many requests one address may make through this grant in a window of
// this many seconds. Either one left out is the platform's.
@field rateLimitRequests = contains(StringField);
@field rateLimitWindowSeconds = contains(StringField);

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 5c2ff14b. The RealmPolicy documentation describes grant-level anonymous traffic rules and preserves the policy’s access-widening constraint.

@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 cross-check complete. No catalog-specific change request: the grant fields, edit/view presentation, and platform-default display are covered by the paired implementation and tests.

@habdelra
habdelra merged commit 79e1453 into main Oct 9, 2026
12 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.

3 participants