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..a95cf9ca 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 access the realm already permits. Its `blocklist` only turns away callers who aren't signed in. No grant can 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/Spec/realm-policy.json b/realm-policy/Spec/realm-policy.json index 4494d564..bd6a05fa 100644 --- a/realm-policy/Spec/realm-policy.json +++ b/realm-policy/Spec/realm-policy.json @@ -16,7 +16,7 @@ "name": "Realm Policy", "summary": "A union of grants that widens a realm's permissions, operation by operation and card type by card type." }, - "readMe": "## What it is\n\nA realm's own permissions are coarse: a user may read the whole realm, or write the whole realm, or neither. A Realm Policy widens them for particular operations on particular card types. It holds a list of rules (`PolicyRule`), each naming a card type and the grants (`OperationGrant`) that apply to cards of that type and its subtypes.\n\nA policy only ever widens access. A caller may invoke an operation on a card if the realm's permissions already allow it, or if any rule whose target type matches the card's type (or one of its ancestors) holds a grant for that operation whose condition is true or absent. Rule order carries no meaning, and nothing in a policy can take away access the realm already gives.\n\n## Putting a policy in force\n\nA policy card on its own does nothing. A realm adopts one by naming it, by URL or realm-prefixed id, in the `policy` attribute of its `realm.json` settings card (`RealmConfig`). The policy card can live in the realm it governs or in another realm, so one policy can govern several realms. The realm, not the card, evaluates the policy: these definitions only describe it.\n\n```json\n{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"cardInfo\": { \"name\": \"Newsroom\" },\n \"policy\": \"https://app.example/org/policies/newsroom\"\n },\n \"meta\": { \"adoptsFrom\": { \"module\": \"https://cardstack.com/base/realm-config\", \"name\": \"RealmConfig\" } }\n }\n}\n```\n\n## The linked example\n\n`RealmPolicy/blog-publishing` governs a blog built from the Blog App cards. Visitors who aren't signed in may open published posts and every category, and find categories in a search; any signed-in user may also read drafts, even one the realm's own permissions don't let read it.\n\nA search is its own operation, `query`: a `read` grant opens a card by its URL only, so categories get a `query` grant beside their `read`. Posts don't. A `query` grant's condition also has to work as a search filter, and a search filter can't test a yes/no field such as `published`: the index stores an empty one as `false` while the saved card leaves it empty. Searching published posts would take a stored text field that says so, such as a status.\n\nOpen it to see what `validate` reports. A `read` sends each post with the cards it links to, so the post's authors (email address included), and any card or file its body links to, reach everyone a `read` grant on posts admits, visitors included, whatever their own type's rules say. `validate` warns about each, for each of the two `read` grants. Before publishing a blog under this policy, have `BlogPost` declare its `read` with `links: 'ids'`, so a read names linked cards without sending them, or accept what the warnings list.\n\n```ts\nimport { RealmPolicy } from '@cardstack/catalog/realm-policy/realm-policy';\n```\n\n## Asking what a policy decides\n\nThe isolated view shows each rule and grant, the issues compiling the policy records (`validate`), and an explain panel that asks the realm what it decides for one caller, one card and one operation (`explain`), without invoking anything. Explain can also answer against an unsaved draft of the rules, for a search, or for a page of a realm's cards. The realm answers only for a card whose realm names this policy, and only to a caller who can read both realms.\n\n## Non-goals\n\n* **Not a deny list.** A policy can't refuse what the realm's permissions already allow.\n* **Not self-governing.** A policy card is authorization infrastructure, so no grant reaches one, or the realm's settings card, for any operation: reading, writing or creating one. Only a caller the realm's own permissions let write may change a policy, and no grant may admit `explain` or `validate`.\n* **Not rate limiting or address blocking.** How hard callers who aren't signed in may use a realm, and which addresses it refuses, are the governed realm's own settings in its `realm.json`, not the policy's.\n" + "readMe": "## What it is\n\nA realm's own permissions are coarse: a user may read the whole realm, or write the whole realm, or neither. A Realm Policy widens them for particular operations on particular card types. It holds a list of rules (`PolicyRule`), each naming a card type and the grants (`OperationGrant`) that apply to cards of that type and its subtypes.\n\nA policy only ever widens access. A caller may invoke an operation on a card if the realm's permissions already allow it, or if any rule whose target type matches the card's type (or one of its ancestors) holds a grant for that operation whose condition is true or absent. Rule order carries no meaning, and nothing in a policy can take away access the realm already gives.\n\n## Putting a policy in force\n\nA policy card on its own does nothing. A realm adopts one by naming it, by URL or realm-prefixed id, in the `policy` attribute of its `realm.json` settings card (`RealmConfig`). The policy card can live in the realm it governs or in another realm, so one policy can govern several realms. The realm, not the card, evaluates the policy: these definitions only describe it.\n\n```json\n{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"cardInfo\": { \"name\": \"Newsroom\" },\n \"policy\": \"https://app.example/org/policies/newsroom\"\n },\n \"meta\": { \"adoptsFrom\": { \"module\": \"https://cardstack.com/base/realm-config\", \"name\": \"RealmConfig\" } }\n }\n}\n```\n\n## The linked example\n\n`RealmPolicy/blog-publishing` governs a blog built from the Blog App cards. Visitors who aren't signed in may open published posts and every category, and find categories in a search; any signed-in user may also read drafts, even one the realm's own permissions don't let read it.\n\nA search is its own operation, `query`: a `read` grant opens a card by its URL only, so categories get a `query` grant beside their `read`. Posts don't. A `query` grant's condition also has to work as a search filter, and a search filter can't test a yes/no field such as `published`: the index stores an empty one as `false` while the saved card leaves it empty. Searching published posts would take a stored text field that says so, such as a status.\n\nOpen it to see what `validate` reports. A `read` sends each post with the cards it links to, so the post's authors (email address included), and any card or file its body links to, reach everyone a `read` grant on posts admits, visitors included, whatever their own type's rules say. `validate` warns about each, for each of the two `read` grants. Before publishing a blog under this policy, have `BlogPost` declare its `read` with `links: 'ids'`, so a read names linked cards without sending them, or accept what the warnings list.\n\n```ts\nimport { RealmPolicy } from '@cardstack/catalog/realm-policy/realm-policy';\n```\n\n## Asking what a policy decides\n\nThe isolated view shows each rule and grant, the issues compiling the policy records (`validate`), and an explain panel that asks the realm what it decides for one caller, one card and one operation (`explain`), without invoking anything. Explain can also answer against an unsaved draft of the rules, for a search, or for a page of a realm's cards. The realm answers only for a card whose realm names this policy, and only to a caller who can read both realms.\n\n## Non-goals\n\n* **Not a deny list.** A policy can't refuse what the realm's permissions already allow.\n* **Not self-governing.** A policy card is authorization infrastructure, so no grant reaches one, or the realm's settings card, for any operation: reading, writing or creating one. Only a caller the realm's own permissions let write may change a policy, and no grant may admit `explain` or `validate`.\n* **Traffic rules apply only to callers who aren't signed in.** A grant can set their address blocklist and rate limit. It never takes away access the realm's own permissions already allow.\n" }, "relationships": { "linkedExamples.0": { diff --git a/realm-policy/realm-policy.gts b/realm-policy/realm-policy.gts index 29602f0f..30b7e09e 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,73 @@ 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?.requests; + let windowSeconds = grant?.rateLimitWindowSeconds || platform?.windowSeconds; + // The realm reports its platform default in its info, so a view that hasn't + // read one has no number to put where the grant sets none. + if (requests == null || windowSeconds == null) { + return 'the platform default'; + } + let unset = !grant?.rateLimitRequests || !grant?.rateLimitWindowSeconds; + // `requests/windowSeconds`, the form the platform's own limit is written in. + return `${requests}/${windowSeconds}${ + unset ? ' (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); + } +