From 4a15275758529bd50c17d03211884cfbe77437dd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 15:33:06 -0400 Subject: [PATCH 1/8] Let a grant on an operation a type declares admit a caller who isn't 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 --- packages/catalog/test-subset.json | 1 + ...m-policy-anonymous-named-operation-test.ts | 566 ++++++++++++++++++ .../tests/realm-policy-compile-test.ts | 77 ++- .../runtime-common/anonymous-admission.ts | 28 +- .../runtime-common/card-operations/policy.ts | 93 ++- .../runtime-common/card-operations/types.ts | 23 +- packages/runtime-common/realm.ts | 3 +- packages/runtime-common/router.ts | 4 +- 8 files changed, 728 insertions(+), 67 deletions(-) create mode 100644 packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 70b88801fbe..8429bd41d33 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -21,6 +21,7 @@ "realmServer": [ "catalog-test-subset-test", "realm-policy-activation-test", + "realm-policy-anonymous-named-operation-test", "realm-policy-anonymous-read-test", "realm-policy-anonymous-search-test", "realm-policy-anonymous-write-test", diff --git a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts new file mode 100644 index 00000000000..f4a745f747a --- /dev/null +++ b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts @@ -0,0 +1,566 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import supertest from 'supertest'; +import type { Test, SuperTest, Response } from 'supertest'; +import { basename, join } from 'path'; +import { dirSync } from 'tmp'; +import { rri, SupportedMimeType } from '@cardstack/runtime-common'; +import type { + LocalPath, + QueuePublisher, + QueueRunner, + Realm, +} from '@cardstack/runtime-common'; +import { + setAnonymousRequestSink, + type AnonymousRequestEvent, +} from '@cardstack/runtime-common/card-operations/telemetry'; +import type { PgAdapter } from '@cardstack/postgres'; +import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms.ts'; +import type { RealmHttpServer as Server } from '../server.ts'; +import { + closeServer, + createJWT, + createVirtualNetwork, + matrixURL, + realmConfigCardJSON, + runTestRealmServerWithRealms, + setupDB, + setupTestDatabaseTemplate, +} from './helpers/index.ts'; +import { setupCatalogTestSubset } from './helpers/catalog-test-subset.ts'; + +// A civic realm nobody may read or write without signing in, governed by a +// policy card in an Org realm. Its policy opens two operations its types +// declare to callers who aren't signed in: signing an open petition, and +// registering for updates. Both are made as the submitter the realm's own +// `realm.json` names. Closing a petition is opened to signed-in callers only, +// and `claim`, which reads the caller, is opened to everyone but admits only +// signed-in callers. +const CIVIC = 'http://127.0.0.1:4444/civic/'; +// A board anyone may read, whose policy opens only a declared write, `sign`, +// to callers who aren't signed in. +const BOARD = 'http://127.0.0.1:4444/board/'; +const ORG = 'http://127.0.0.1:4444/org/'; +const CIVIC_POLICY = `${ORG}policies/civic`; +const BOARD_POLICY = `${ORG}policies/board`; +const EDITOR = '@editor:localhost'; +const SUBMITTER = '@submitter:localhost'; +const ORG_ADMIN = '@org-admin:localhost'; + +// Callers' addresses come from the ranges reserved for documentation. +const VISITOR = '192.0.2.10'; + +const REALM_POLICY = { + module: rri('@cardstack/catalog/realm-policy/realm-policy'), + name: 'RealmPolicy', +}; + +const CIVIC_MODULE = ` + import { contains, field, CardDef } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + import { operation, params, actor } from "@cardstack/base/operations"; + export class Petition extends CardDef { + @field title = contains(StringField); + @field status = contains(StringField); + @field signature = contains(StringField); + + @operation static sign = { + base: 'transform', + params: { name: StringField }, + set: { signature: params('name') }, + }; + @operation static close = { + base: 'transform', + set: { status: 'closed' }, + }; + @operation static claim = { + base: 'transform', + set: { signature: actor() }, + }; + } + export class Signup extends CardDef { + @field email = contains(StringField); + @field source = contains(StringField); + + @operation static register = { + base: 'create', + of: () => Signup, + params: { email: StringField }, + fill: { email: params('email'), source: 'form' }, + }; + } +`; + +function type(realm: string, name: string) { + return { module: `${realm}civic`, name }; +} + +const PETITION = type(CIVIC, 'Petition'); +const SIGNUP = type(CIVIC, 'Signup'); + +const OPEN = `${CIVIC}petitions/open`; +const CLOSED = `${CIVIC}petitions/closed`; +const MISSING = `${CIVIC}petitions/nowhere`; +const BOARD_PETITION = `${BOARD}petitions/open`; + +function petition(title: string, status: string) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { title, status }, + meta: { adoptsFrom: { module: '../civic', name: 'Petition' } }, + }, + }); +} + +function rule( + targetType: { module: string; name: string }, + grants: Record[], +) { + return { targetType, grants }; +} + +function policyCard(rules: unknown[]) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { rules }, + meta: { adoptsFrom: REALM_POLICY }, + }, + }); +} + +const POLICY = policyCard([ + rule(PETITION, [ + { + operation: 'sign', + anonymous: true, + actingUser: 'submitter', + where: '.status == "open"', + }, + { operation: 'close' }, + { operation: 'claim', anonymous: true, actingUser: 'submitter' }, + ]), + rule(SIGNUP, [ + { operation: 'register', anonymous: true, actingUser: 'submitter' }, + ]), +]); + +module(basename(import.meta.filename), function (hooks) { + let civic: Realm; + let board: Realm; + let org: Realm; + let request: SuperTest; + let server: Server; + let records: AnonymousRequestEvent[]; + + setupCatalogTestSubset(hooks); + + async function start({ + dbAdapter, + publisher, + runner, + }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let result = await runTestRealmServerWithRealms({ + virtualNetwork: createVirtualNetwork(), + realmsRootPath: join(dirSync().name, 'realm_server_1'), + clientAddress: { trustedProxyHops: 1, infraAddresses: [] }, + realms: [ + { + realmURL: new URL(CIVIC), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Civic', + policy: CIVIC_POLICY, + config: { submitter: SUBMITTER }, + }), + 'civic.gts': CIVIC_MODULE, + 'petitions/open.json': petition('Fix the park', 'open'), + 'petitions/closed.json': petition('Move the library', 'closed'), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + [ORG_ADMIN]: ['read'], + }, + }, + { + realmURL: new URL(BOARD), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Board', + policy: BOARD_POLICY, + config: { submitter: SUBMITTER }, + }), + 'civic.gts': CIVIC_MODULE, + 'petitions/open.json': petition('Paint the fence', 'open'), + }, + permissions: { + '*': ['read'], + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + }, + }, + { + realmURL: new URL(ORG), + fileSystem: { + 'realm.json': realmConfigCardJSON({ name: 'Org' }), + 'policies/civic.json': POLICY, + 'policies/board.json': policyCard([ + rule(type(BOARD, 'Petition'), [ + { operation: 'sign', anonymous: true, actingUser: 'submitter' }, + ]), + ]), + }, + permissions: { [ORG_ADMIN]: ['read', 'write', 'realm-owner'] }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + server = result.testRealmHttpServer; + request = supertest(server); + civic = result.realms.find((realm) => realm.url === CIVIC)!; + board = result.realms.find((realm) => realm.url === BOARD)!; + org = result.realms.find((realm) => realm.url === ORG)!; + } + + async function stop() { + for (let realm of [civic, board, org]) { + realm.__testOnlyClearCaches(); + realm.unsubscribe(); + } + await closeServer(server); + resetCatalogRealms(); + } + + let templateDatabase = setupTestDatabaseTemplate(hooks, { + key: import.meta.filename, + build: async (args) => { + await start(args); + return stop; + }, + }); + + setupDB(hooks, { + templateDatabase, + beforeEach: async (dbAdapter, publisher, runner) => { + await start({ dbAdapter, publisher, runner }); + records = []; + setAnonymousRequestSink((record) => records.push(record)); + }, + afterEach: async () => { + setAnonymousRequestSink(undefined); + await stop(); + }, + }); + + function invoke( + name: string, + rest: { href?: string; data?: unknown } = {}, + ): Record { + return { op: 'invoke', 'boxel:name': name, ...rest }; + } + + function operations( + realm: string, + entries: Record[], + authorization?: string, + ) { + let req = request + .post(`${new URL(realm).pathname}_operations`) + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set('X-Forwarded-For', VISITOR); + if (authorization) { + req = req.set('Authorization', authorization); + } + return req.send(JSON.stringify({ 'boxel:operations': entries })); + } + + function sign(url: string, name: string) { + return operations(CIVIC, [invoke('sign', { href: url, data: { name } })]); + } + + function register(email: string) { + return operations(CIVIC, [ + invoke('register', { + data: { + email, + meta: { + adoptsFrom: { module: rri(`${CIVIC}civic`), name: 'Signup' }, + }, + }, + }), + ]); + } + + async function stored(realm: Realm, url: string) { + let content = await realm.operationCore.readFileAsText( + `${new URL(url).pathname.slice(new URL(realm.url).pathname.length)}.json` as LocalPath, + ); + return content === undefined + ? undefined + : ( + JSON.parse(content) as { + data: { attributes: Record }; + } + ).data.attributes; + } + + async function setCivic(fields: { anonymousRateLimit?: unknown }) { + await civic.write( + 'realm.json', + realmConfigCardJSON({ + name: 'Civic', + policy: CIVIC_POLICY, + config: { submitter: SUBMITTER }, + ...fields, + }), + ); + await civic.indexing(); + } + + function unauthenticated(response: Response, label: string, assert: Assert) { + assert.strictEqual(response.status, 401, `${label}: 401 ${response.text}`); + assert.strictEqual( + response.body?.errors?.[0]?.code, + 'actor-required', + `${label}: told to authenticate`, + ); + } + + function editor() { + return `Bearer ${createJWT(civic, EDITOR, ['read', 'write', 'realm-owner'])}`; + } + + test('a caller who is not signed in signs an open petition through the operation its type declares, made as the user the realm names', async function (assert) { + let response = await sign(OPEN, 'Ada'); + assert.strictEqual(response.status, 200, response.text); + assert.deepEqual( + await stored(civic, OPEN), + { title: 'Fix the park', status: 'open', signature: 'Ada' }, + 'the operation set only what it declares', + ); + let [record] = records.filter((r) => r.outcome === 'admitted'); + assert.deepEqual(record?.actingUsers, [SUBMITTER], 'made as the submitter'); + assert.strictEqual(record?.clientIP, VISITOR); + }); + + test('a declared write is admitted only where its grant holds, and every refusal is the same 401', async function (assert) { + let closed = await sign(CLOSED, 'Ada'); + let missing = await sign(MISSING, 'Ada'); + unauthenticated(closed, 'a closed petition', assert); + unauthenticated(missing, 'a missing petition', assert); + assert.strictEqual( + closed.text, + missing.text, + 'a closed petition and a missing one are refused alike', + ); + assert.notOk( + (await stored(civic, CLOSED))?.signature, + 'the closed petition is unsigned', + ); + }); + + test('a grant on a declared write opens nothing else, and an operation opened only to signed-in callers stays closed', async function (assert) { + unauthenticated( + await operations(CIVIC, [invoke('close', { href: OPEN })]), + 'an operation no grant opens to such callers', + assert, + ); + unauthenticated( + await operations(CIVIC, [ + invoke('update', { + href: OPEN, + data: { + type: 'card', + attributes: { signature: 'Mallory' }, + meta: { + adoptsFrom: { module: rri(`${CIVIC}civic`), name: 'Petition' }, + }, + }, + }), + ]), + 'the base operation the declared one is built on', + assert, + ); + assert.deepEqual( + await stored(civic, OPEN), + { title: 'Fix the park', status: 'open' }, + 'nothing was written', + ); + let closedBy = await operations( + CIVIC, + [invoke('close', { href: OPEN })], + editor(), + ); + assert.strictEqual(closedBy.status, 200, closedBy.text); + assert.strictEqual( + (await stored(civic, OPEN))?.status, + 'closed', + 'a signed-in caller the policy admits still closes it', + ); + }); + + test('a declared create gets an id the realm mints, filled as its template says', async function (assert) { + let response = await register('ada@example.com'); + assert.strictEqual(response.status, 200, response.text); + let id = ( + response.body as { 'atomic:results': { data: { id: string } }[] } + )['atomic:results'][0].data.id; + assert.true(id.startsWith(CIVIC), `minted in the realm: ${id}`); + assert.deepEqual( + await stored(civic, id), + { email: 'ada@example.com', source: 'form' }, + 'the card holds what the template fills', + ); + let [record] = records.filter((r) => r.outcome === 'admitted'); + assert.deepEqual(record?.actingUsers, [SUBMITTER], 'made as the submitter'); + }); + + test('a declared operation that reads the caller is warned about, and admits no caller who is not signed in', async function (assert) { + let policy = await civic.getCompiledPolicy(); + assert.deepEqual( + policy?.issues.map(({ code, path, severity }) => ({ + code, + path, + severity, + })), + [ + { + code: 'anonymous-grant-reads-actor', + path: 'rules[0].grants[2].operation', + severity: 'warning', + }, + ], + ); + assert.deepEqual( + policy?.anonymous, + { operations: ['register', 'sign'] }, + 'it opens nothing to such callers', + ); + unauthenticated( + await operations(CIVIC, [invoke('claim', { href: OPEN })]), + 'claim', + assert, + ); + assert.notOk((await stored(civic, OPEN))?.signature, 'nothing was written'); + }); + + test('a declared write is counted one unit, and one over the limit gets 429 and writes nothing', async function (assert) { + await setCivic({ anonymousRateLimit: { requests: 1, windowSeconds: 60 } }); + assert.strictEqual((await sign(OPEN, 'Ada')).status, 200); + let over = await sign(OPEN, 'Grace'); + assert.strictEqual(over.status, 429, over.text); + assert.strictEqual(over.body?.errors?.[0]?.code, 'rate-limited'); + assert.ok(over.headers['retry-after'], 'says when to retry'); + assert.strictEqual( + (await stored(civic, OPEN))?.signature, + 'Ada', + 'the second signature was not written', + ); + }); + + test('a capability check answers such a caller about declared operations as the gate would', async function (assert) { + let response = await request + .post(`${new URL(CIVIC).pathname}_capabilities`) + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', SupportedMimeType.JSON) + .set('X-Forwarded-For', VISITOR) + .send({ + checks: [ + { target: OPEN, operation: 'sign' }, + { target: CLOSED, operation: 'sign' }, + { target: OPEN, operation: 'close' }, + { target: OPEN, operation: 'claim' }, + { target: SIGNUP, operation: 'register' }, + ], + }); + assert.strictEqual(response.status, 200, response.text); + assert.deepEqual( + (response.body.checks as { allowed: boolean }[]).map( + (check) => check.allowed, + ), + [true, false, false, false, true], + ); + }); + + test('a realm anyone may read answers a capability check about a declared write it opens to such callers', async function (assert) { + let response = await request + .post(`${new URL(BOARD).pathname}_capabilities`) + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', SupportedMimeType.JSON) + .set('X-Forwarded-For', VISITOR) + .send({ + checks: [ + { target: BOARD_PETITION, operation: 'sign' }, + { target: BOARD_PETITION, operation: 'update' }, + ], + }); + assert.strictEqual(response.status, 200, response.text); + assert.deepEqual( + (response.body.checks as { allowed: boolean }[]).map( + (check) => check.allowed, + ), + [true, false], + 'the signature form may be shown, and editing may not', + ); + let signed = await operations(BOARD, [ + invoke('sign', { href: BOARD_PETITION, data: { name: 'Ada' } }), + ]); + assert.strictEqual(signed.status, 200, signed.text); + assert.strictEqual( + (await stored(board, BOARD_PETITION))?.signature, + 'Ada', + 'and the petition is signed', + ); + }); + + test('explain judges such a caller’s declared write by the grants that opt in to them', async function (assert) { + let explain = async (target: string, operation: string) => { + let response = await request + .post(`${new URL(ORG).pathname}_operations`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set( + 'Authorization', + `Bearer ${createJWT(org, ORG_ADMIN, ['read', 'write', 'realm-owner'])}`, + ) + .send( + JSON.stringify({ + 'boxel:operations': [ + invoke('explain', { + href: CIVIC_POLICY, + data: { actor: '', target, operation }, + }), + ], + }), + ); + if (response.status !== 200) { + throw new Error( + `explain answered ${response.status}: ${response.text}`, + ); + } + return response.body['atomic:results'][0]; + }; + assert.strictEqual( + (await explain(OPEN, 'sign')).decision, + 'allowed', + 'an open petition', + ); + assert.strictEqual( + (await explain(CLOSED, 'sign')).decision, + 'denied', + 'a closed petition', + ); + }); +}); diff --git a/packages/realm-server/tests/realm-policy-compile-test.ts b/packages/realm-server/tests/realm-policy-compile-test.ts index a4ca19ec2e5..87a40097030 100644 --- a/packages/realm-server/tests/realm-policy-compile-test.ts +++ b/packages/realm-server/tests/realm-policy-compile-test.ts @@ -49,7 +49,7 @@ function classroomModule(rosterField: string) { return ` import { contains, containsMany, field, CardDef } from "@cardstack/base/card-api"; import StringField from "@cardstack/base/string"; - import { operation } from "@cardstack/base/operations"; + import { operation, actor } from "@cardstack/base/operations"; export class Classroom extends CardDef { @field ${rosterField} = containsMany(StringField); @field status = contains(StringField); @@ -76,6 +76,10 @@ function classroomModule(rosterField: string) { set: { status: 'locked' }, nonGrantable: true, }; + @operation static claim = { + base: 'transform', + set: { status: actor() }, + }; @operation static listOpen = { base: 'query', query: { filter: { type: () => Classroom } }, @@ -834,7 +838,7 @@ module(basename(import.meta.filename), function (hooks) { assert.notOk(policy?.uncompilable, 'the policy as a whole compiled'); }); - test('a grant opens a base operation to callers who are not signed in, and a write names who it is made as', async function (assert) { + test('a grant opens a base operation, or one a type declares, to callers who are not signed in, and a write names who it is made as', async function (assert) { const OPEN_CLASSROOM = { module: `${EDUCATION}classroom`, name: 'OpenClassroom', @@ -847,8 +851,12 @@ module(basename(import.meta.filename), function (hooks) { targetType: CLASSROOM, grants: [ { operation: 'read', anonymous: true }, - // A custom operation, built on a base one. - { operation: 'appendActivity', anonymous: true }, + // An operation the type declares, built on a base one. + { + operation: 'appendActivity', + anonymous: true, + actingUser: 'feedbackWriter', + }, // A named query. { operation: 'listOpen', anonymous: true }, // A write that names nobody to make it as, and one whose key is @@ -865,6 +873,12 @@ module(basename(import.meta.filename), function (hooks) { { operation: 'query', anonymous: true, where: '.status == "open"' }, // Opened to signed-in callers only. { operation: 'delete' }, + // A declared operation that reads the caller. + { + operation: 'claim', + anonymous: true, + actingUser: 'feedbackWriter', + }, ], }, { @@ -889,11 +903,6 @@ module(basename(import.meta.filename), function (hooks) { severity, })), [ - { - code: 'anonymous-not-base-operation', - path: 'rules[0].grants[1].anonymous', - severity: 'inactive', - }, { code: 'anonymous-not-base-operation', path: 'rules[0].grants[2].anonymous', @@ -909,19 +918,28 @@ module(basename(import.meta.filename), function (hooks) { path: 'rules[0].grants[4].actingUser', severity: 'inactive', }, + { + code: 'anonymous-grant-reads-actor', + path: 'rules[0].grants[9].operation', + severity: 'warning', + }, { code: 'anonymous-grant-reads-actor', path: 'rules[1].grants[0].where', severity: 'warning', }, ], - 'a custom operation, a named query and a write with no acting user are refused; a grant that reads the caller is warned about and kept', + 'a named query and a write with no acting user are refused; a grant that reads the caller, in its condition or its operation, is warned about and kept', ); let grants = policy?.rules[0]?.grants ?? []; assert.deepEqual( grants.map(({ path, anonymous }) => ({ path, anonymous })), [ { path: 'rules[0].grants[0]', anonymous: {} }, + { + path: 'rules[0].grants[1]', + anonymous: { actingUserKey: 'feedbackWriter' }, + }, { path: 'rules[0].grants[5]', anonymous: { actingUserKey: 'feedbackWriter' }, @@ -929,29 +947,44 @@ module(basename(import.meta.filename), function (hooks) { { path: 'rules[0].grants[6]', anonymous: {} }, { path: 'rules[0].grants[7]', anonymous: {} }, { path: 'rules[0].grants[8]', anonymous: undefined }, + { path: 'rules[0].grants[9]', anonymous: undefined }, ], - 'the refused grants are left out, only the grants that opt in carry the opt-in, and only a write names who it is made as', + 'the refused grants are left out, only the grants that opt in carry the opt-in, only a write names who it is made as, and a declared operation that reads the caller applies only to signed-in callers', ); - assert.ok(grants[3]?.filter, 'the anonymous query grant has a filter'); + assert.ok(grants[4]?.filter, 'the anonymous query grant has a filter'); let readsCaller = policy?.rules[1]?.grants[0]; assert.true( readsCaller?.where?.readsActor, 'a predicate that reads the caller says so', ); assert.notOk( - grants[3]?.where?.readsActor, + grants[4]?.where?.readsActor, 'and one that reads only the card does not', ); assert.deepEqual( policy?.anonymous, - { operations: ['query', 'read', 'readSource', 'transform'] }, + { + operations: [ + 'appendActivity', + 'query', + 'read', + 'readSource', + 'transform', + ], + }, 'the operations opened to callers who are not signed in, leaving out the one whose only grant reads the caller', ); + let opened = await education.getAnonymousAdmission(); assert.deepEqual( - [...(await education.getAnonymousAdmission())].sort(), - ['query', 'read', 'readSource', 'transform'], + [...opened.operations].sort(), + ['appendActivity', 'query', 'read', 'readSource', 'transform'], 'and the realm reads them from the compiled policy', ); + assert.deepEqual( + [...opened.writes].sort(), + ['appendActivity', 'transform'], + 'along with which of them write', + ); }); test('a query grant with no search filter opens nothing to callers who are not signed in', async function (assert) { @@ -973,7 +1006,10 @@ module(basename(import.meta.filename), function (hooks) { ['policy-not-filterable'], ); assert.strictEqual(policy?.anonymous, undefined); - assert.strictEqual((await education.getAnonymousAdmission()).size, 0); + assert.strictEqual( + (await education.getAnonymousAdmission()).operations.size, + 0, + ); }); test('a policy that opens nothing, or that does not compile, opens nothing to callers who are not signed in', async function (assert) { @@ -983,7 +1019,10 @@ module(basename(import.meta.filename), function (hooks) { undefined, 'a policy that opens nothing records nothing', ); - assert.strictEqual((await education.getAnonymousAdmission()).size, 0); + assert.strictEqual( + (await education.getAnonymousAdmission()).operations.size, + 0, + ); await writeTo( org, @@ -998,7 +1037,7 @@ module(basename(import.meta.filename), function (hooks) { 'the policy does not compile', ); assert.strictEqual( - (await education.getAnonymousAdmission()).size, + (await education.getAnonymousAdmission()).operations.size, 0, 'and opens nothing', ); diff --git a/packages/runtime-common/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts index 69a8894d540..fef40ed21d1 100644 --- a/packages/runtime-common/anonymous-admission.ts +++ b/packages/runtime-common/anonymous-admission.ts @@ -24,10 +24,9 @@ import { } from './card-operations/acting-users.ts'; import type { CommitBatchOptions } from './card-operations/coordinator.ts'; import type { OperationScope } from './card-operations/dispatch.ts'; +import type { AnonymousOpenings } from './card-operations/policy.ts'; import { - ANONYMOUS_ELIGIBLE_OPERATIONS, OperationFailure, - isWrite, type OperationError, } from './card-operations/types.ts'; import { createResponse } from './create-response.ts'; @@ -59,7 +58,11 @@ import type { ResponseWithNodeStream } from './virtual-network.ts'; // one there (empty means any at all), and how it counts against the realm's // anonymous rate limit. export interface AnonymousDispatch { - operations: readonly string[]; + // The operations the route invokes, any one of which the policy has to open + // to such callers. `any` is a route that runs whatever its request names, + // admitted wherever the policy opens anything; `any-write`, wherever it + // opens a write, whether a base operation or one a type declares. + operations: readonly string[] | 'any' | 'any-write'; // - `once-served`: one unit once the request serves what it asked for // (see `AnonymousCaller.served`). // - `by-handler`: the handler counts what the request costs, before it does @@ -134,7 +137,7 @@ export const ANONYMOUS_STYLESHEET: AnonymousDispatch = { // one. It is counted by the handler, one unit for each pair it asks about, // before any is checked (see `AnonymousAdmission.count`). export const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { - operations: [], + operations: 'any', counted: 'by-handler', }; // A card+json write, made as the user the admitting grant names. Counted one @@ -154,7 +157,7 @@ export const ANONYMOUS_CARD_DELETE: AnonymousDispatch = { // The write lane of a capability check from a caller whose read the realm's // ACL allowed: the check invokes nothing, so it counts for nothing. export const ANONYMOUS_WRITE_CHECK: AnonymousDispatch = { - operations: ANONYMOUS_ELIGIBLE_OPERATIONS.filter(isWrite), + operations: 'any-write', counted: 'never', }; // The operations envelope runs whatever its entries name, each judged by the @@ -163,7 +166,7 @@ export const ANONYMOUS_WRITE_CHECK: AnonymousDispatch = { // admitted, and one that only reads, one unit per entry once its reads have // run. export const ANONYMOUS_OPERATIONS: AnonymousDispatch = { - operations: [], + operations: 'any', counted: 'by-handler', }; @@ -178,8 +181,8 @@ export interface AnonymousAdmissionEnvironment { limiter: AnonymousRateLimiter; // Whether the realm names a policy at all. hasPolicy(): Promise; - // The operations the realm's policy opens to such callers. - openedOperations(): Promise>; + // What the realm's policy opens to such callers. + openedOperations(): Promise; // The realm's limit and blocklist for such callers. access(): Promise; // Who an acting-user key names in the realm's current `realm.json` @@ -223,13 +226,14 @@ export class AnonymousAdmission { } let opened = await this.#env.openedOperations(); // A route that runs whatever its request names is admitted wherever the - // policy opens anything to such callers, and recorded as `*`. + // policy opens anything of that kind to such callers, and recorded as `*`. + let wanted = dispatch.operations; let operation = - dispatch.operations.length === 0 - ? opened.size > 0 + wanted === 'any' || wanted === 'any-write' + ? (wanted === 'any' ? opened.operations : opened.writes).size > 0 ? '*' : undefined - : dispatch.operations.find((name) => opened.has(name)); + : wanted.find((name) => opened.operations.has(name)); if (operation === undefined) { return undefined; } diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index 9b496c1a40b..7223638cf41 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -256,7 +256,19 @@ export interface RealmPolicyCacheEnvironment extends PolicyCompileEnvironment { // commit moves the index of the realm holding the card, which reaches the // cache as any move does, and the refresh that follows compiles what the card // holds now. -const NO_OPERATIONS: ReadonlySet = new Set(); +// What a realm's policy opens to callers who aren't signed in: the operations +// its live grants open to them, and which of those write, made as the user the +// grant's `actingUser` names. A capability check asks about a write lane, which +// a named write opens as much as a base one does. +export interface AnonymousOpenings { + operations: ReadonlySet; + writes: ReadonlySet; +} + +const NO_OPERATIONS: AnonymousOpenings = { + operations: new Set(), + writes: new Set(), +}; // Whether any grant in a policy card's attributes, as stored, opts in to // callers who aren't signed in. A card where none does opens nothing to them @@ -348,7 +360,7 @@ export class RealmPolicyCache { // remember the answer as long as a compile is kept. If there isn't one, we // return "none" without compiling. If a fresh compile is already cached, we // skip that check and use it directly. - async anonymousAdmission(): Promise> { + async anonymousAdmission(): Promise { let compiled: CompiledRealmPolicy | undefined; try { let card = await this.#env.policyCard(); @@ -373,9 +385,19 @@ export class RealmPolicyCache { ); return NO_OPERATIONS; } - return compiled?.uncompilable || !compiled?.anonymous - ? NO_OPERATIONS - : new Set(compiled.anonymous.operations); + if (compiled?.uncompilable || !compiled?.anonymous) { + return NO_OPERATIONS; + } + let operations = new Set(compiled.anonymous.operations); + let writes = new Set(); + for (let rule of compiled.rules) { + for (let grant of rule.grants) { + if (grant.anonymous?.actingUserKey && operations.has(grant.operation)) { + writes.add(grant.operation); + } + } + } + return { operations, writes }; } async #opensAnythingToAnonymous(card: string): Promise { @@ -1237,8 +1259,12 @@ async function compileDocument( } let anonymous: CompiledOperationGrant['anonymous']; if (grant?.anonymous === true) { + // A named query runs a stored query on the search lane, where a + // grant's filter is all that scopes it, so it stays a contract for + // signed-in callers. Every other operation is one invocation of its + // base, and is eligible when its base is. if ( - granted.base !== operation || + (granted.base === 'query' && operation !== 'query') || !ANONYMOUS_ELIGIBLE_OPERATIONS.includes(granted.base) ) { issue( @@ -1248,26 +1274,38 @@ async function compileDocument( (name) => `\`${name}\``, ).join( ', ', - )} can, under their own names, and a custom operation or a named query can't`, + )} can, and an operation a type declares on one of them, but a named query can't`, ); continue; } - // Used exactly as written, so the key the policy shows is the key the - // realm looks up. One that is blank names nothing. - let actingUserKey = - typeof grant.actingUser === 'string' && grant.actingUser.trim() - ? grant.actingUser - : undefined; - if (isWrite(granted.base) && !actingUserKey) { + // An operation whose program, template or output reads `actor()` is + // refused to a caller with no actor before it runs, so opening it to + // one would admit nobody. The grant still applies to signed-in + // callers. + if (granted.readsActor) { issue( - 'anonymous-write-without-acting-user', - `${grantPath}.actingUser`, - `\`${operation}\` writes, so a grant opening it to callers who aren't signed in has to name the setting in the realm's \`realm.json\` whose value is the user those writes are made as, in \`actingUser\``, + 'anonymous-grant-reads-actor', + `${grantPath}.operation`, + `this grant opens \`${operation}\` to callers who aren't signed in, but ${resolved.name}'s \`${operation}\` uses \`actor()\`, and a caller who isn't signed in has no actor, so it never admits one. It still applies to signed-in callers. To open it to anyone, declare an operation that doesn't read \`actor()\`; a write such a caller makes is made as the user \`actingUser\` names`, ); - continue; + } else { + // Used exactly as written, so the key the policy shows is the key the + // realm looks up. One that is blank names nothing. + let actingUserKey = + typeof grant.actingUser === 'string' && grant.actingUser.trim() + ? grant.actingUser + : undefined; + if (isWrite(granted.base) && !actingUserKey) { + issue( + 'anonymous-write-without-acting-user', + `${grantPath}.actingUser`, + `\`${operation}\` writes, so a grant opening it to callers who aren't signed in has to name the setting in the realm's \`realm.json\` whose value is the user those writes are made as, in \`actingUser\``, + ); + continue; + } + anonymous = + isWrite(granted.base) && actingUserKey ? { actingUserKey } : {}; } - anonymous = - isWrite(granted.base) && actingUserKey ? { actingUserKey } : {}; } let where = readPredicate(grant?.where); if (where === 'malformed') { @@ -1624,7 +1662,12 @@ function grantedOperation( definition: Definition, name: string, ): - | { base: BaseOperation; nonGrantable: boolean; invalid: boolean } + | { + base: BaseOperation; + nonGrantable: boolean; + invalid: boolean; + readsActor: boolean; + } | undefined { let declared = isDefinitionFreeBaseOperation(name) ? undefined @@ -1634,10 +1677,16 @@ function grantedOperation( base: declared.base, nonGrantable: declared.nonGrantable === true, invalid: declared.invalid === true, + readsActor: declared.readsActor === true, }; } return carriesBuiltIn(definition.type, name) - ? { base: name as BaseOperation, nonGrantable: false, invalid: false } + ? { + base: name as BaseOperation, + nonGrantable: false, + invalid: false, + readsActor: false, + } : undefined; } diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index 9f605193474..a02e3cfacb4 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -551,25 +551,26 @@ export type PolicyIssueCode = // `grant-reaches-ungranted-type`. The grant is kept, for the same reason. | 'render-reaches-ungranted-type' // A grant that opts in to admitting callers who aren't signed in on an - // operation that isn't one of `ANONYMOUS_ELIGIBLE_OPERATIONS` invoked under - // its own name: a custom operation or a named query. A type's own - // declaration under a base operation's name is that operation, and is - // eligible. The grant is left out. + // operation whose base isn't one of `ANONYMOUS_ELIGIBLE_OPERATIONS`, or on a + // named query. An operation a type declares on an eligible base, under its + // own name or a base operation's, is eligible. The grant is left out. | 'anonymous-not-base-operation' // A grant that opts in to admitting callers who aren't signed in on a write, // without naming the `realm.json` setting that says which user the write is // made as. The grant is left out. | 'anonymous-write-without-acting-user' // A grant that opts in to admitting callers who aren't signed in, whose - // `where` reads `actor()`. The realm doesn't evaluate such a `where` for a - // caller with no actor, so the grant never admits one, whatever else its - // `where` says. It is kept for signed-in callers. + // `where` reads `actor()`, or whose operation's program, template or output + // does. The realm evaluates neither for a caller with no actor, so the grant + // never admits one. It is kept for signed-in callers. | 'anonymous-grant-reads-actor'; -// The operations a grant may open to callers who aren't signed in: the -// grantable base operations, invoked under their own names. A custom operation -// or a named query is a contract its author wrote for signed-in callers, and -// stays one. +// The base operations a grant may open to callers who aren't signed in, under +// their own names or through an operation a type declares on one of them. Such +// an 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 +// is the exception: it runs a stored query on the search lane, and stays a +// contract for signed-in callers. export const ANONYMOUS_ELIGIBLE_OPERATIONS: readonly BaseOperationName[] = [ 'read', 'readSource', diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index a8dab3fc0c9..f230659bd04 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -379,6 +379,7 @@ import { noteRealmIndexMoved, RealmPolicyCache, realmPolicyRef, + type AnonymousOpenings, type CompiledRealmPolicy, type PolicyCompileEnvironment, } from './card-operations/policy.ts'; @@ -16011,7 +16012,7 @@ export class Realm { // as `RealmPolicyCache.anonymousAdmission()` reads them: empty for a realm // that names no policy, and for one whose policy opens none or won't // compile. - async getAnonymousAdmission(): Promise> { + async getAnonymousAdmission(): Promise { return await this.#policyCache.anonymousAdmission(); } diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index efba31733cb..298e153f075 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -310,7 +310,7 @@ export interface RouteOptions { aclOnly?: true; // 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 // against the realm's anonymous rate limit. Only a route that // consumes the ACL's outcome can take this. A route without it answers such // a caller as the ACL does. @@ -318,7 +318,7 @@ export interface RouteOptions { } export interface AnonymousRouteOptions { - operations: readonly string[]; + operations: readonly string[] | 'any' | 'any-write'; counted: 'once-served' | 'by-handler' | 'never'; } From 722ea4eb892fdc6b766bf6057acf997c69edb087 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 15:40:32 -0400 Subject: [PATCH 2/8] Pin the catalog subset to the OperationGrant.anonymous comment that admits declared operations Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 8429bd41d33..a0957390794 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "2845e727868e364cb5197bd78d5e657131cce18a", + "revision": "3bf9e8a17bb239a8c8c058fd41c91e8202945269", "files": [ { "path": "realm-policy/realm-policy.gts", From 105c785e350f95c0edfb17b771d0c6c23203b94e Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 15:50:39 -0400 Subject: [PATCH 3/8] Refuse a visitor an opened operation a subtype redeclares to read the 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 --- ...m-policy-anonymous-named-operation-test.ts | 105 +++++++++++++----- .../tests/realm-policy-compile-test.ts | 3 +- .../runtime-common/anonymous-admission.ts | 2 +- .../runtime-common/card-operations/gate.ts | 9 +- .../runtime-common/card-operations/policy.ts | 36 +++--- packages/runtime-common/realm.ts | 6 +- 6 files changed, 110 insertions(+), 51 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts index f4a745f747a..6a39772b34c 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts @@ -79,6 +79,14 @@ const CIVIC_MODULE = ` set: { signature: actor() }, }; } + // Redeclares \`sign\` to read the caller, so a grant on Petition that opens + // \`sign\` admits no caller who isn't signed in to one of these. + export class LocalPetition extends Petition { + @operation static sign = { + base: 'transform', + set: { signature: actor() }, + }; + } export class Signup extends CardDef { @field email = contains(StringField); @field source = contains(StringField); @@ -100,16 +108,17 @@ const PETITION = type(CIVIC, 'Petition'); const SIGNUP = type(CIVIC, 'Signup'); const OPEN = `${CIVIC}petitions/open`; +const LOCAL = `${CIVIC}petitions/local`; const CLOSED = `${CIVIC}petitions/closed`; const MISSING = `${CIVIC}petitions/nowhere`; const BOARD_PETITION = `${BOARD}petitions/open`; -function petition(title: string, status: string) { +function petition(title: string, status: string, name = 'Petition') { return JSON.stringify({ data: { type: 'card', attributes: { title, status }, - meta: { adoptsFrom: { module: '../civic', name: 'Petition' } }, + meta: { adoptsFrom: { module: '../civic', name } }, }, }); } @@ -182,6 +191,11 @@ module(basename(import.meta.filename), function (hooks) { 'civic.gts': CIVIC_MODULE, 'petitions/open.json': petition('Fix the park', 'open'), 'petitions/closed.json': petition('Move the library', 'closed'), + 'petitions/local.json': petition( + 'Light the square', + 'open', + 'LocalPetition', + ), }, permissions: { [EDITOR]: ['read', 'write', 'realm-owner'], @@ -341,6 +355,32 @@ module(basename(import.meta.filename), function (hooks) { return `Bearer ${createJWT(civic, EDITOR, ['read', 'write', 'realm-owner'])}`; } + async function explain(target: string, operation: string) { + let response = await request + .post(`${new URL(ORG).pathname}_operations`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set( + 'Authorization', + `Bearer ${createJWT(org, ORG_ADMIN, ['read', 'write', 'realm-owner'])}`, + ) + .send( + JSON.stringify({ + 'boxel:operations': [ + invoke('explain', { + href: CIVIC_POLICY, + data: { actor: '', target, operation }, + }), + ], + }), + ); + if (response.status !== 200) { + throw new Error(`explain answered ${response.status}: ${response.text}`); + } + return response.body['atomic:results'][0]; + } + test('a caller who is not signed in signs an open petition through the operation its type declares, made as the user the realm names', async function (assert) { let response = await sign(OPEN, 'Ada'); assert.strictEqual(response.status, 200, response.text); @@ -444,7 +484,7 @@ module(basename(import.meta.filename), function (hooks) { ); assert.deepEqual( policy?.anonymous, - { operations: ['register', 'sign'] }, + { operations: ['register', 'sign'], writes: ['register', 'sign'] }, 'it opens nothing to such callers', ); unauthenticated( @@ -455,6 +495,38 @@ module(basename(import.meta.filename), function (hooks) { assert.notOk((await stored(civic, OPEN))?.signature, 'nothing was written'); }); + test('a subtype that redeclares an opened operation to read the caller admits no caller who is not signed in, and capability checks and explain say so', async function (assert) { + let capabilities = await request + .post(`${new URL(CIVIC).pathname}_capabilities`) + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', SupportedMimeType.JSON) + .set('X-Forwarded-For', VISITOR) + .send({ + checks: [ + { target: OPEN, operation: 'sign' }, + { target: LOCAL, operation: 'sign' }, + ], + }); + assert.strictEqual(capabilities.status, 200, capabilities.text); + assert.deepEqual( + (capabilities.body.checks as { allowed: boolean }[]).map( + (check) => check.allowed, + ), + [true, false], + "the parent's petition may be signed, and the subtype's may not", + ); + assert.strictEqual( + (await explain(LOCAL, 'sign')).decision, + 'denied', + 'explain agrees', + ); + unauthenticated(await sign(LOCAL, 'Ada'), 'the subtype', assert); + assert.notOk( + (await stored(civic, LOCAL))?.signature, + 'nothing was written', + ); + }); + test('a declared write is counted one unit, and one over the limit gets 429 and writes nothing', async function (assert) { await setCivic({ anonymousRateLimit: { requests: 1, windowSeconds: 60 } }); assert.strictEqual((await sign(OPEN, 'Ada')).status, 200); @@ -525,33 +597,6 @@ module(basename(import.meta.filename), function (hooks) { }); test('explain judges such a caller’s declared write by the grants that opt in to them', async function (assert) { - let explain = async (target: string, operation: string) => { - let response = await request - .post(`${new URL(ORG).pathname}_operations`) - .set('X-HTTP-Method-Override', 'QUERY') - .set('Accept', SupportedMimeType.BoxelOperations) - .set('Content-Type', SupportedMimeType.BoxelOperations) - .set( - 'Authorization', - `Bearer ${createJWT(org, ORG_ADMIN, ['read', 'write', 'realm-owner'])}`, - ) - .send( - JSON.stringify({ - 'boxel:operations': [ - invoke('explain', { - href: CIVIC_POLICY, - data: { actor: '', target, operation }, - }), - ], - }), - ); - if (response.status !== 200) { - throw new Error( - `explain answered ${response.status}: ${response.text}`, - ); - } - return response.body['atomic:results'][0]; - }; assert.strictEqual( (await explain(OPEN, 'sign')).decision, 'allowed', diff --git a/packages/realm-server/tests/realm-policy-compile-test.ts b/packages/realm-server/tests/realm-policy-compile-test.ts index 87a40097030..008226c815f 100644 --- a/packages/realm-server/tests/realm-policy-compile-test.ts +++ b/packages/realm-server/tests/realm-policy-compile-test.ts @@ -971,8 +971,9 @@ module(basename(import.meta.filename), function (hooks) { 'readSource', 'transform', ], + writes: ['appendActivity', 'transform'], }, - 'the operations opened to callers who are not signed in, leaving out the one whose only grant reads the caller', + 'the operations opened to callers who are not signed in, and which of them write, leaving out the one whose only grant reads the caller', ); let opened = await education.getAnonymousAdmission(); assert.deepEqual( diff --git a/packages/runtime-common/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts index fef40ed21d1..a0c3f535c9b 100644 --- a/packages/runtime-common/anonymous-admission.ts +++ b/packages/runtime-common/anonymous-admission.ts @@ -55,7 +55,7 @@ import type { ResponseWithNodeStream } from './virtual-network.ts'; // 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 // anonymous rate limit. export interface AnonymousDispatch { // The operations the route invokes, any one of which the policy has to open diff --git a/packages/runtime-common/card-operations/gate.ts b/packages/runtime-common/card-operations/gate.ts index 2e3afa61879..5d9dbc487c3 100644 --- a/packages/runtime-common/card-operations/gate.ts +++ b/packages/runtime-common/card-operations/gate.ts @@ -649,8 +649,15 @@ async function decide( // A caller who isn't signed in is admitted only by a grant that opts in to // them. Every other grant was written for signed-in callers, and admitting // anyone at all through one would widen it past what its author wrote. + // An operation whose program, template or output reads `actor()` has no + // actor to read for such a caller, and is refused before it runs, so no + // grant admits one to it. The definition is the one the target's own type + // resolves, which may be a subtype's redeclaration of what a grant on its + // parent opened. if (scope.caller.kind === 'anonymous') { - matched = matched.filter(({ grant }) => grant.anonymous); + matched = definition.readsActor + ? [] + : matched.filter(({ grant }) => grant.anonymous); // Such a caller's write is made as the user its grant names, so a grant // whose acting user doesn't resolve to one who may write the realm admits // nothing. diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index 7223638cf41..279a74d0c18 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -83,7 +83,9 @@ export interface CompiledRealmPolicy { // The operations some live grant opens to callers who aren't signed in. // Absent when no grant does, which is what lets a realm answer such a caller // without judging anything: it reads this, and nothing else of the policy. - anonymous?: { operations: string[] }; + // Of those, the ones whose grants open a write, made as the user the grant's + // `actingUser` names. + anonymous?: { operations: string[]; writes: string[] }; } export interface CompiledPolicyRule { @@ -388,16 +390,10 @@ export class RealmPolicyCache { if (compiled?.uncompilable || !compiled?.anonymous) { return NO_OPERATIONS; } - let operations = new Set(compiled.anonymous.operations); - let writes = new Set(); - for (let rule of compiled.rules) { - for (let grant of rule.grants) { - if (grant.anonymous?.actingUserKey && operations.has(grant.operation)) { - writes.add(grant.operation); - } - } - } - return { operations, writes }; + return { + operations: new Set(compiled.anonymous.operations), + writes: new Set(compiled.anonymous.writes), + }; } async #opensAnythingToAnonymous(card: string): Promise { @@ -1720,13 +1716,15 @@ function safeURL( } // The operations the policy's live grants open to callers who aren't signed -// in, recorded on the compiled policy when there are any. A query grant that -// has no search filter admits no search, and a grant whose `where` reads the -// caller admits nobody without one, so neither opens anything. +// in, and which of those write, recorded on the compiled policy when there +// are any. A query grant that has no search filter admits no search, and a +// grant whose `where` reads the caller admits nobody without one, so neither +// opens anything. function anonymousSummary( rules: CompiledPolicyRule[], ): Pick { let operations = new Set(); + let writes = new Set(); for (let rule of rules) { for (let grant of rule.grants) { if ( @@ -1735,11 +1733,19 @@ function anonymousSummary( (grant.operation !== 'query' || grant.filter) ) { operations.add(grant.operation); + if (grant.anonymous.actingUserKey) { + writes.add(grant.operation); + } } } } return operations.size > 0 - ? { anonymous: { operations: [...operations].sort() } } + ? { + anonymous: { + operations: [...operations].sort(), + writes: [...writes].sort(), + }, + } : {}; } diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index f230659bd04..3ddbab96672 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -2394,9 +2394,9 @@ interface RequestDispatch { answersWithoutCredentials?: boolean; // What the dispatch 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 for it to admit them here (empty - // means any at all), and how it counts against the realm's anonymous rate - // limit. A dispatch without it never admits a caller who isn't signed in. + // policy has to open to such callers for it to admit them here (`any` + // for any at all, `any-write` for any write), and how it counts against + // the realm's anonymous rate limit. A dispatch without it never admits a caller who isn't signed in. anonymous?: AnonymousDispatch; handle: () => Promise; } From a413e470ed1d5d3f06281279f7f068fd9862f48c Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:03:57 -0400 Subject: [PATCH 4/8] Record a visitor refused an operation that reads the caller as reads-actor, and explain it as actor-required Co-Authored-By: Claude Opus 5.5 --- .claude/skills/operations-diagnosis/SKILL.md | 2 +- ...alm-policy-anonymous-named-operation-test.ts | 10 ++++++---- packages/runtime-common/anonymous-admission.ts | 7 +++---- .../runtime-common/card-operations/explain.ts | 2 ++ .../card-operations/gate-trace.ts | 6 +++++- packages/runtime-common/card-operations/gate.ts | 17 +++++++++-------- .../runtime-common/card-operations/telemetry.ts | 1 + .../runtime-common/card-operations/types.ts | 4 +++- packages/runtime-common/realm.ts | 8 ++++---- packages/runtime-common/router.ts | 8 ++++---- 10 files changed, 38 insertions(+), 27 deletions(-) diff --git a/.claude/skills/operations-diagnosis/SKILL.md b/.claude/skills/operations-diagnosis/SKILL.md index b5746dd3ae0..c5f9f980e30 100644 --- a/.claude/skills/operations-diagnosis/SKILL.md +++ b/.claude/skills/operations-diagnosis/SKILL.md @@ -116,7 +116,7 @@ The fault line is plain text, so its filter needs no unwrap. `--no-follow` retur The fields that answer "why" (cost fields are `policy-performance`'s): - `outcome` — `allow`, `deny`, `error`, or `pending` (trap 5). -- `reason` — `granted`; `pending`; a refusal before any predicate ran: `non-grantable` (the operation is declared `nonGrantable`, or is an explain or a validate), `query-lane` (a query, which only the search lane grants), `authorization-infrastructure` (the realm's policy card or config card, or any policy card), `unmatchable-target` (a card whose index row is an error row, a type for anything but `create`, a file for anything but `readSource`, or stored bytes with no type, such as module source), `no-grant` (no rule on the type has a grant for the operation, or the realm names no policy at all); `predicate` (every matching grant's predicate ran and none held); `target-missing` (no index row); `stored-card-refused` (under the lock: the card is gone, stored as another type, or stored as a policy card); `policy-unavailable` (the policy did not compile); `predicate-threw`; `gate-failed` (anything else that threw). +- `reason` — `granted`; `pending`; a refusal before any predicate ran: `non-grantable` (the operation is declared `nonGrantable`, or is an explain or a validate), `query-lane` (a query, which only the search lane grants), `authorization-infrastructure` (the realm's policy card or config card, or any policy card), `unmatchable-target` (a card whose index row is an error row, a type for anything but `create`, a file for anything but `readSource`, or stored bytes with no type, such as module source), `no-grant` (no rule on the type has a grant for the operation, or the realm names no policy at all), `reads-actor` (a grant opens the operation to a caller who isn't signed in, but the operation as the target's type declares it reads `actor()`); `predicate` (every matching grant's predicate ran and none held); `target-missing` (no index row); `stored-card-refused` (under the lock: the card is gone, stored as another type, or stored as a policy card); `policy-unavailable` (the policy did not compile); `predicate-threw`; `gate-failed` (anything else that threw). - `rules` / `grants` — every rule matched on the target's type and every grant in them for the operation, comma-separated in policy order: what a denial was judged by. Empty `rules` means no rule governs the type; `rules` with empty `grants` means a rule governs it but grants nothing under that name. - `rule` / `grant` — the one that admitted the invocation; null on anything but an allow. - `tier` — `none` (no predicate ran), `stored` (the card's own source), `snapshot` (at least one read the index row). diff --git a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts index 6a39772b34c..8d7bb7ddc26 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts @@ -485,7 +485,7 @@ module(basename(import.meta.filename), function (hooks) { assert.deepEqual( policy?.anonymous, { operations: ['register', 'sign'], writes: ['register', 'sign'] }, - 'it opens nothing to such callers', + 'claim is left out of what it opens to such callers', ); unauthenticated( await operations(CIVIC, [invoke('claim', { href: OPEN })]), @@ -515,10 +515,12 @@ module(basename(import.meta.filename), function (hooks) { [true, false], "the parent's petition may be signed, and the subtype's may not", ); + let explained = await explain(LOCAL, 'sign'); + assert.strictEqual(explained.decision, 'denied', 'explain agrees'); assert.strictEqual( - (await explain(LOCAL, 'sign')).decision, - 'denied', - 'explain agrees', + explained.reason, + 'actor-required', + 'and says the caller would have to sign in, rather than that no grant names the operation', ); unauthenticated(await sign(LOCAL, 'Ada'), 'the subtype', assert); assert.notOk( diff --git a/packages/runtime-common/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts index a0c3f535c9b..dea7c8b2dd7 100644 --- a/packages/runtime-common/anonymous-admission.ts +++ b/packages/runtime-common/anonymous-admission.ts @@ -53,10 +53,9 @@ import type { ResponseWithNodeStream } from './virtual-network.ts'; // grant admits, exactly as it does for a signed-in caller. // ============================================================================ -// 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 (`any` for any at all, `any-write` for any write), and how it counts against the realm's -// anonymous rate limit. +// What a route would run for a caller who isn't signed in: what the realm's +// policy has to open to such callers for it to admit one there, and how it +// counts against the realm's anonymous rate limit. export interface AnonymousDispatch { // The operations the route invokes, any one of which the policy has to open // to such callers. `any` is a route that runs whatever its request names, diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index 106bd70d7e4..a38a2632a63 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -864,6 +864,8 @@ function refusalReason( return 'unmatchable-target'; case 'no-grant': return 'no-grant'; + case 'reads-actor': + return 'actor-required'; default: return trace.rules.some(({ grants }) => grants.length > 0) ? 'predicate-false' diff --git a/packages/runtime-common/card-operations/gate-trace.ts b/packages/runtime-common/card-operations/gate-trace.ts index 51d2f6c8ca0..04ce8e13b7e 100644 --- a/packages/runtime-common/card-operations/gate-trace.ts +++ b/packages/runtime-common/card-operations/gate-trace.ts @@ -33,7 +33,11 @@ export type GateTraceRefusal = // to match, which is what module source and an empty path are. | 'unmatchable-target' // No rule governing the target's type has a grant for the operation. - | 'no-grant'; + | 'no-grant' + // A grant opens the operation to a caller who isn't signed in, but the + // operation as the target's type resolves it reads `actor()`, which such a + // caller has none of, so it is refused before it runs. + | 'reads-actor'; // What a predicate said when the gate evaluated it. export type GateTraceOutcome = 'held' | 'did-not-hold' | 'threw'; diff --git a/packages/runtime-common/card-operations/gate.ts b/packages/runtime-common/card-operations/gate.ts index 5d9dbc487c3..8b23f4c239e 100644 --- a/packages/runtime-common/card-operations/gate.ts +++ b/packages/runtime-common/card-operations/gate.ts @@ -649,15 +649,16 @@ async function decide( // A caller who isn't signed in is admitted only by a grant that opts in to // them. Every other grant was written for signed-in callers, and admitting // anyone at all through one would widen it past what its author wrote. - // An operation whose program, template or output reads `actor()` has no - // actor to read for such a caller, and is refused before it runs, so no - // grant admits one to it. The definition is the one the target's own type - // resolves, which may be a subtype's redeclaration of what a grant on its - // parent opened. if (scope.caller.kind === 'anonymous') { - matched = definition.readsActor - ? [] - : matched.filter(({ grant }) => grant.anonymous); + matched = matched.filter(({ grant }) => grant.anonymous); + // An operation whose program, template or output reads `actor()` has no + // actor to read for such a caller, and is refused before it runs, so a + // grant that opens it to one admits nobody. The definition is the one the + // target's own type resolves, which may be a subtype's redeclaration of + // what a grant on its parent opened. + if (matched.length > 0 && definition.readsActor) { + return refuse('reads-actor'); + } // Such a caller's write is made as the user its grant names, so a grant // whose acting user doesn't resolve to one who may write the realm admits // nothing. diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index 4e5c377fec6..a1bbb3ed43f 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -334,6 +334,7 @@ export type PolicyDecisionReason = | 'authorization-infrastructure' | 'unmatchable-target' | 'no-grant' + | 'reads-actor' | 'predicate' | 'target-missing' | 'stored-card-refused' diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index a02e3cfacb4..3617317c385 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -1135,7 +1135,9 @@ export type PolicyExplanationReason = // The actor presented no credentials, and the ACL does not allow an // anonymous caller this lane. The policy admits only an authenticated // caller, so the invocation is answered with a 401 before anything about - // the target is read. + // the target is read. Also the answer where a grant opens the operation to + // such a caller but the operation, as the target's type declares it, reads + // `actor()`. | 'actor-required' // The realm names a policy it cannot load. | 'policy-unloadable'; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 3ddbab96672..1765adb4b29 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -2393,10 +2393,10 @@ interface RequestDispatch { // any other read. answersWithoutCredentials?: boolean; // What the dispatch 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 for it to admit them here (`any` - // for any at all, `any-write` for any write), and how it counts against - // the realm's anonymous rate limit. A dispatch without it never admits a caller who isn't signed in. + // grant could admit one: what the realm's policy has to open to such + // callers for it to admit them here, and how it counts against the realm's + // anonymous rate limit (see `AnonymousDispatch`). A dispatch without it + // never admits a caller who isn't signed in. anonymous?: AnonymousDispatch; handle: () => Promise; } diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index 298e153f075..9ce6c21eef2 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -310,10 +310,10 @@ export interface RouteOptions { aclOnly?: true; // 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 (`any` for any at all), and how the request counts - // against the realm's anonymous rate limit. Only a route that - // consumes the ACL's outcome can take this. A route without it answers such - // a caller as the ACL does. + // open to such callers (`any` for any at all, `any-write` for any write), + // and how the request counts against the realm's anonymous rate limit. Only + // a route that consumes the ACL's outcome can take this. A route without it + // answers such a caller as the ACL does. anonymous?: AnonymousRouteOptions; } From db42f0a2e9e1a0769c91bfabaff5347c15353745 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:15:17 -0400 Subject: [PATCH 5/8] Explain a visitor refused an operation that reads the caller with its own reason, and pin the panel that says what an empty person field asks Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- packages/host/tests/integration/realm-policy-test.gts | 6 ++++++ .../tests/realm-policy-anonymous-named-operation-test.ts | 4 ++-- packages/runtime-common/card-operations/explain.ts | 2 +- packages/runtime-common/card-operations/types.ts | 8 +++++--- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index a0957390794..9dabaea139f 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "3bf9e8a17bb239a8c8c058fd41c91e8202945269", + "revision": "e143743806edaafef4959ec6336ea0d2f55c988f", "files": [ { "path": "realm-policy/realm-policy.gts", diff --git a/packages/host/tests/integration/realm-policy-test.gts b/packages/host/tests/integration/realm-policy-test.gts index 6ccb551144c..c49672c4802 100644 --- a/packages/host/tests/integration/realm-policy-test.gts +++ b/packages/host/tests/integration/realm-policy-test.gts @@ -590,6 +590,12 @@ module('Integration | realm policy', function (hooks) { await renderClassroomPolicy(); assert.dom('[data-test-realm-policy-explain]').exists(); + assert + .dom('[data-test-explain-actor-hint]') + .hasText( + "Leave this empty to check what someone who isn't signed in can do.", + 'the person field says how to ask about someone who is not signed in', + ); assert.dom('[data-test-explain-submit]').isDisabled('nothing to ask yet'); await ask(TEACHER, `${testRealmURL}classrooms/room-204`, 'delete'); diff --git a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts index 8d7bb7ddc26..9effdbd75b9 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts @@ -519,8 +519,8 @@ module(basename(import.meta.filename), function (hooks) { assert.strictEqual(explained.decision, 'denied', 'explain agrees'); assert.strictEqual( explained.reason, - 'actor-required', - 'and says the caller would have to sign in, rather than that no grant names the operation', + 'reads-actor', + 'and says why, rather than that no grant names the operation', ); unauthenticated(await sign(LOCAL, 'Ada'), 'the subtype', assert); assert.notOk( diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index a38a2632a63..1adc6edadaf 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -865,7 +865,7 @@ function refusalReason( case 'no-grant': return 'no-grant'; case 'reads-actor': - return 'actor-required'; + return 'reads-actor'; default: return trace.rules.some(({ grants }) => grants.length > 0) ? 'predicate-false' diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index 3617317c385..e88d230bc4f 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -1135,10 +1135,12 @@ export type PolicyExplanationReason = // The actor presented no credentials, and the ACL does not allow an // anonymous caller this lane. The policy admits only an authenticated // caller, so the invocation is answered with a 401 before anything about - // the target is read. Also the answer where a grant opens the operation to - // such a caller but the operation, as the target's type declares it, reads - // `actor()`. + // the target is read. | 'actor-required' + // A grant opens the operation to a caller who isn't signed in, but the + // operation, as the target's type declares it, reads `actor()`, which such + // a caller has none of, so the grant doesn't apply to them. + | 'reads-actor' // The realm names a policy it cannot load. | 'policy-unloadable'; From d5459db640162d195976b8a89cac77e48cd60356 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:35:12 -0400 Subject: [PATCH 6/8] Pin the catalog subset to the explain panel merged with catalog main Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 9dabaea139f..567448ba5e1 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "e143743806edaafef4959ec6336ea0d2f55c988f", + "revision": "553bce9005f531767b95b84ee7b556dc349d3987", "files": [ { "path": "realm-policy/realm-policy.gts", From 3b4335ccec68187bf630611cab838678f667d962 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:38:21 -0400 Subject: [PATCH 7/8] Keep the named-operation test's rate-limited requests inside one window Co-Authored-By: Claude Opus 5.5 --- .../tests/realm-policy-anonymous-named-operation-test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts index 9effdbd75b9..5857677c1ed 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-named-operation-test.ts @@ -19,6 +19,7 @@ import type { PgAdapter } from '@cardstack/postgres'; import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms.ts'; import type { RealmHttpServer as Server } from '../server.ts'; import { + clearOfRateLimitWindowEdge, closeServer, createJWT, createVirtualNetwork, @@ -340,6 +341,7 @@ module(basename(import.meta.filename), function (hooks) { }), ); await civic.indexing(); + await clearOfRateLimitWindowEdge(fields.anonymousRateLimit); } function unauthenticated(response: Response, label: string, assert: Assert) { @@ -530,7 +532,7 @@ module(basename(import.meta.filename), function (hooks) { }); test('a declared write is counted one unit, and one over the limit gets 429 and writes nothing', async function (assert) { - await setCivic({ anonymousRateLimit: { requests: 1, windowSeconds: 60 } }); + await setCivic({ anonymousRateLimit: { requests: 1, windowSeconds: 600 } }); assert.strictEqual((await sign(OPEN, 'Ada')).status, 200); let over = await sign(OPEN, 'Grace'); assert.strictEqual(over.status, 429, over.text); From 51a78e0b9c623df4bf319736b1519f2bc70b6124 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:49:26 -0400 Subject: [PATCH 8/8] Pin the catalog subset to the explain panel merged with catalog main Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 567448ba5e1..9731fe5f4f2 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "553bce9005f531767b95b84ee7b556dc349d3987", + "revision": "776baef8ccd1bc4c370711797374a79905c4e965", "files": [ { "path": "realm-policy/realm-policy.gts",