From 8d6abb09db389954aa1b0124bdb94c654380320a Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 8 Oct 2026 09:39:41 -0400 Subject: [PATCH 01/12] Open anonymous access through the grant's where clause and BXL expressions on the grant Co-Authored-By: Claude Opus 5.5 --- realm-policy/RealmPolicy/blog-publishing.json | 15 +- realm-policy/Spec/operation-grant.json | 11 +- realm-policy/Spec/policy-rule.json | 15 +- realm-policy/realm-policy.gts | 410 ++++++++++++------ 4 files changed, 308 insertions(+), 143 deletions(-) diff --git a/realm-policy/RealmPolicy/blog-publishing.json b/realm-policy/RealmPolicy/blog-publishing.json index 915d33c2..b8ed978b 100644 --- a/realm-policy/RealmPolicy/blog-publishing.json +++ b/realm-policy/RealmPolicy/blog-publishing.json @@ -11,8 +11,7 @@ "grants": [ { "operation": "read", - "anonymous": true, - "where": ".published == true" + "where": "actor() == \"anonymous\" and .published == true" }, { "operation": "read" @@ -27,18 +26,24 @@ "grants": [ { "operation": "read", - "anonymous": true + "where": "actor() == \"anonymous\"" + }, + { + "operation": "read" }, { "operation": "query", - "anonymous": true + "where": "actor() == \"anonymous\"" + }, + { + "operation": "query" } ] } ], "cardInfo": { "name": "Blog publishing", - "summary": "Visitors open published posts and open and search every category; any signed-in user may also read drafts." + "summary": "Visitors open published posts and open and search every category; any signed-in user also reads drafts and searches categories." } }, "meta": { diff --git a/realm-policy/Spec/operation-grant.json b/realm-policy/Spec/operation-grant.json index e47013db..242d02bc 100644 --- a/realm-policy/Spec/operation-grant.json +++ b/realm-policy/Spec/operation-grant.json @@ -16,7 +16,7 @@ "name": "Operation Grant", "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", "containedExamples": [ { "operation": "read" @@ -31,13 +31,14 @@ }, { "operation": "read", - "anonymous": true, - "where": ".published == true" + "where": "actor() == \"anonymous\" and .published == true", + "rateLimitRequests": "realmConfig(\"visitorRequests\")", + "blocklist": "realmConfig(\"blockedIps\")" }, { "operation": "create", - "anonymous": true, - "actingUser": "submitter" + "where": "actor() == \"anonymous\"", + "actingUser": "realmConfig(\"submitter\")" } ] }, diff --git a/realm-policy/Spec/policy-rule.json b/realm-policy/Spec/policy-rule.json index 5c43f10a..5fe62a07 100644 --- a/realm-policy/Spec/policy-rule.json +++ b/realm-policy/Spec/policy-rule.json @@ -16,7 +16,7 @@ "name": "Policy Rule", "summary": "A target card type plus the operation grants that apply to it." }, - "readMe": "## What it is\n\nA rule in a `RealmPolicy`: a target card type (`targetType`, a code ref) and the grants (`grants`, a list of `OperationGrant`) that apply to it. A rule governs its target type and every subtype of it, so a rule on a base type reaches every card that adopts from it.\n\n```ts\nimport { PolicyRule } from '@cardstack/catalog/realm-policy/realm-policy';\n\n@field rules = containsMany(PolicyRule);\n```\n\nIn a policy document a rule is plain data. A relative module in `targetType` resolves against the policy card:\n\n```json\n{\n \"targetType\": { \"module\": \"../../7af9aa-blog-app/blog-post\", \"name\": \"BlogPost\" },\n \"grants\": [\n { \"operation\": \"read\", \"anonymous\": true, \"where\": \".published == true\" },\n { \"operation\": \"read\" }\n ]\n}\n```\n\n## How rules combine\n\nRules don't compete. For a card, every rule whose target type is the card's type or one of its ancestors contributes its grants, and an operation is allowed when any one of those grants admits it. Several rules may name the same type; their order means nothing.\n\n## The examples\n\n1. **Blog posts**: visitors open published posts, and any signed-in user reads every post, drafts included.\n2. **Blog categories**: everyone, signed in or not, opens and searches every category.\n\n## Editing\n\nThe edit format lists the rule's grants, each opening with the one-line summary the policy's view shows for it, with buttons to add and remove grants.\n\n## Non-goals\n\nA rule has no condition of its own and no priority. Conditions belong to grants, and a type with no rule gets nothing beyond the realm's own permissions.\n", + "readMe": "## What it is\n\nA rule in a `RealmPolicy`: a target card type (`targetType`, a code ref) and the grants (`grants`, a list of `OperationGrant`) that apply to it. A rule governs its target type and every subtype of it, so a rule on a base type reaches every card that adopts from it.\n\n```ts\nimport { PolicyRule } from '@cardstack/catalog/realm-policy/realm-policy';\n\n@field rules = containsMany(PolicyRule);\n```\n\nIn a policy document a rule is plain data. A relative module in `targetType` resolves against the policy card:\n\n```json\n{\n \"targetType\": { \"module\": \"../../7af9aa-blog-app/blog-post\", \"name\": \"BlogPost\" },\n \"grants\": [\n { \"operation\": \"read\", \"where\": \"actor() == \\\"anonymous\\\" and .published == true\" },\n { \"operation\": \"read\" }\n ]\n}\n```\n\n## How rules combine\n\nRules don't compete. For a card, every rule whose target type is the card's type or one of its ancestors contributes its grants, and an operation is allowed when any one of those grants admits it. Several rules may name the same type; their order means nothing.\n\n## The examples\n\n1. **Blog posts**: visitors open published posts, and any signed-in user reads every post, drafts included.\n2. **Blog categories**: everyone, signed in or not, opens and searches every category.\n\n## Editing\n\nThe edit format lists the rule's grants, each opening with the one-line summary the policy's view shows for it, with buttons to add and remove grants.\n\n## Non-goals\n\nA rule has no condition of its own and no priority. Conditions belong to grants, and a type with no rule gets nothing beyond the realm's own permissions.\n", "containedExamples": [ { "targetType": { @@ -26,8 +26,7 @@ "grants": [ { "operation": "read", - "anonymous": true, - "where": ".published == true" + "where": "actor() == \"anonymous\" and .published == true" }, { "operation": "read" @@ -42,11 +41,17 @@ "grants": [ { "operation": "read", - "anonymous": true + "where": "actor() == \"anonymous\"" + }, + { + "operation": "read" }, { "operation": "query", - "anonymous": true + "where": "actor() == \"anonymous\"" + }, + { + "operation": "query" } ] } diff --git a/realm-policy/realm-policy.gts b/realm-policy/realm-policy.gts index 29602f0f..6f93fd6b 100644 --- a/realm-policy/realm-policy.gts +++ b/realm-policy/realm-policy.gts @@ -11,9 +11,11 @@ import { contains, containsMany, field, + realmInfo, realmURL, serializeCard, } from 'https://cardstack.com/base/card-api'; +import DefaultCardDefTemplate from 'https://cardstack.com/base/default-templates/isolated-and-edit'; import CodeRefField from 'https://cardstack.com/base/code-ref'; import { JsonField } from 'https://cardstack.com/base/json-field'; import { @@ -28,7 +30,6 @@ import { import PolicyPredicateField from '@cardstack/catalog/fields/policy-predicate/policy-predicate'; import { subscribeToRealm } from '@cardstack/runtime-common'; import StringField from 'https://cardstack.com/base/string'; -import BooleanField from 'https://cardstack.com/base/boolean'; import { BoxelInput, Button, @@ -50,10 +51,70 @@ import ShieldCheckIcon from '@cardstack/boxel-icons/shield-check'; // These definitions describe a policy; nothing in them evaluates one. The // realm does, and `explain` asks it what it decides. +// The rate limit a grant counts callers who aren't signed in against where it +// sets none: the platform's, which every realm reports in its info. A policy +// card's views remember it for their realm as they're created, so the grants +// they render, which are fields and see no realm info of their own, can show +// it beside their own limit. +interface RateLimit { + requests: number; + windowSeconds: number; +} +const platformLimits = new Map(); + +function rememberPlatformLimit(card: RealmPolicy | undefined): void { + let realm = card?.[realmURL]?.href; + let limit = ( + card?.[realmInfo] as { anonymousRateLimitDefault?: RateLimit } | undefined + )?.anonymousRateLimitDefault; + if (realm && limit) { + platformLimits.set(realm, limit); + } +} + +function platformLimitFor(grant: OperationGrant | undefined): RateLimit | undefined { + let realm = grant?.[realmURL]?.href; + return realm ? platformLimits.get(realm) : undefined; +} + +// Whether a grant's condition names the caller who isn't signed in, which is +// how a grant admits one: `actor() == "anonymous"`. Read from the text the +// author wrote, as the realm reads it once the text compiles. +function namesAnonymous(grant: OperationGrant | undefined): boolean { + let source = (grant?.where as { source?: string } | undefined)?.source; + return typeof source === 'string' && source.includes('"anonymous"'); +} + +// A grant's rate limit in one line: each half from its expression where it +// has one, and the platform's otherwise. +function rateLimitLine(grant: OperationGrant | undefined): string { + let platform = platformLimitFor(grant); + let requests = + grant?.rateLimitRequests ?? + (platform ? `${platform.requests}` : 'the platform default'); + let windowSeconds = + grant?.rateLimitWindowSeconds ?? + (platform ? `${platform.windowSeconds}s` : 'the platform default'); + let fromPlatform = + !grant?.rateLimitRequests || !grant?.rateLimitWindowSeconds; + return `${requests} per ${windowSeconds}${ + fromPlatform && platform ? ' (platform default where unset)' : '' + }`; +} + // A grant in one line: its operation, then its condition (or `always`), then -// whether it admits callers who aren't signed in. A grant reads the same in -// every listing, so an edit form can be matched up with the policy's view. +// whether it admits callers who aren't signed in, and how. A grant reads the +// same in every listing, so an edit form can be matched up with the policy's +// view. class OperationGrantView extends Component { + get opensToAnonymous() { + return namesAnonymous(this.args.model as OperationGrant); + } + + get rateLimit() { + return rateLimitLine(this.args.model as OperationGrant); + } +