From 8e30641ca7e275dd72c38afd5424379aec0fb7d2 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 13:36:04 -0400 Subject: [PATCH 1/5] Admit anonymous writes as the acting user the realm's config names A grant that opts a write in to callers who aren't signed in admits it only where its acting-user key names, in the governed realm's current realm.json config, a Matrix user who may write the realm; the write is made as that user. card+json POST/PATCH/DELETE and /_operations admit such callers. A write is counted once every entry is admitted and before anything is written, one unit per entry, and a batch that doesn't fit gets 429 with nothing written. Every refusal a non-reader would see as not found is the anonymous 401. Capability checks and explain judge such writes through the same acting-user check. Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 1 + .../tests/realm-policy-anonymous-read-test.ts | 19 +- .../realm-policy-anonymous-write-test.ts | 587 ++++++++++++++++++ .../card-operations/acting-users.ts | 78 +++ .../card-operations/coordinator.ts | 15 +- .../card-operations/dispatch.ts | 15 + .../runtime-common/card-operations/explain.ts | 6 - .../runtime-common/card-operations/gate.ts | 51 +- .../runtime-common/card-operations/index.ts | 5 + .../card-operations/telemetry.ts | 7 + packages/runtime-common/realm.ts | 326 +++++++--- packages/runtime-common/router.ts | 6 +- 12 files changed, 1018 insertions(+), 98 deletions(-) create mode 100644 packages/realm-server/tests/realm-policy-anonymous-write-test.ts create mode 100644 packages/runtime-common/card-operations/acting-users.ts diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index db84da1565c..70b88801fbe 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -23,6 +23,7 @@ "realm-policy-activation-test", "realm-policy-anonymous-read-test", "realm-policy-anonymous-search-test", + "realm-policy-anonymous-write-test", "realm-policy-archived-seal-test", "realm-policy-batch-test", "realm-policy-capabilities-test", diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index f4414d5ed89..bdbf7f6304e 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -192,8 +192,8 @@ module(basename(import.meta.filename), function (hooks) { anonymous: true, where: '.status == "published"', }, - // Opts a write in to such callers, which no route admits them - // to, so it never admits one. + // Opts a write in to such callers as an acting user the + // newsroom's config doesn't name, so it never admits one. { operation: 'update', anonymous: true, @@ -552,9 +552,9 @@ module(basename(import.meta.filename), function (hooks) { ); let update = await explainAnonymous(NEWSROOM_POLICY, PUBLISHED, 'update'); assert.strictEqual( - update.reason, - 'actor-required', - 'a write is answered as its route answers it, whatever the policy opens', + update.decision, + 'denied', + 'a write whose acting user the realm does not name admits nothing', ); }); @@ -808,18 +808,21 @@ module(basename(import.meta.filename), function (hooks) { .send(JSON.stringify({ filter: { type: ARTICLE } })), ], [ - 'an operations batch', + 'an operations batch that creates', () => request .post(`${path}_operations`) - .set('X-HTTP-Method-Override', 'QUERY') .set('Accept', SupportedMimeType.BoxelOperations) .set('Content-Type', SupportedMimeType.BoxelOperations) .set('X-Forwarded-For', VISITOR) .send( JSON.stringify({ 'boxel:operations': [ - { op: 'invoke', 'boxel:name': 'read', href: PUBLISHED }, + { + op: 'invoke', + 'boxel:name': 'create', + data: JSON.parse(document).data, + }, ], }), ), diff --git a/packages/realm-server/tests/realm-policy-anonymous-write-test.ts b/packages/realm-server/tests/realm-policy-anonymous-write-test.ts new file mode 100644 index 00000000000..2c10f869c47 --- /dev/null +++ b/packages/realm-server/tests/realm-policy-anonymous-write-test.ts @@ -0,0 +1,587 @@ +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 newsroom nobody may read or write without signing in, governed by a +// policy card in an Org realm. Its policy lets callers who aren't signed in +// leave feedback and edit open drafts, each as a user the newsroom's own +// `realm.json` names, and opts in a few writes whose acting users don't +// resolve to anyone who may write the newsroom. +const NEWSROOM = 'http://127.0.0.1:4444/newsroom/'; +const ORG = 'http://127.0.0.1:4444/org/'; +const NEWSROOM_POLICY = `${ORG}policies/newsroom`; +const EDITOR = '@editor:localhost'; +const SUBMITTER = '@submitter:localhost'; +const READER = '@reader: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 NEWSROOM_MODULE = ` + import { contains, containsMany, field, CardDef } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + export class Article extends CardDef { + @field headline = contains(StringField); + @field status = contains(StringField); + @field authorIds = containsMany(StringField); + } + export class Feedback extends CardDef { + @field message = contains(StringField); + } + export class Comment extends CardDef { + @field message = contains(StringField); + } + export class Note extends CardDef { + @field message = contains(StringField); + } + export class Tip extends CardDef { + @field message = contains(StringField); + } +`; + +function type(name: string) { + return { module: `${NEWSROOM}newsroom`, name }; +} + +function adoptsFrom(name: string) { + return { module: rri(`${NEWSROOM}newsroom`), name }; +} + +const ARTICLE = type('Article'); +const FEEDBACK = type('Feedback'); +const COMMENT = type('Comment'); +const NOTE = type('Note'); +const TIP = type('Tip'); + +const OPEN_DRAFT = `${NEWSROOM}articles/open`; +const CLOSED_DRAFT = `${NEWSROOM}articles/closed`; +const MISSING = `${NEWSROOM}articles/nowhere`; + +// The acting users the newsroom names: one who may write it, one who may only +// read it, and a value that names nobody. +const NEWSROOM_CONFIG = { + submitter: SUBMITTER, + reader: READER, + bogus: 'not a user', +}; + +function article(headline: string, status: string, authorIds: string[] = []) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { headline, status, authorIds }, + meta: { adoptsFrom: { module: '../newsroom', name: 'Article' } }, + }, + }); +} + +function rule( + targetType: { module: string; name: string }, + grants: Record[], +) { + return { targetType, grants }; +} + +const POLICY = JSON.stringify({ + data: { + type: 'card', + attributes: { + rules: [ + rule(FEEDBACK, [ + { operation: 'create', anonymous: true, actingUser: 'submitter' }, + ]), + rule(ARTICLE, [ + { + operation: 'update', + anonymous: true, + actingUser: 'submitter', + where: '.status == "open"', + }, + // Reads the caller, so it never admits one who isn't signed in. + { + operation: 'delete', + anonymous: true, + actingUser: 'submitter', + where: '.authorIds | any(. == actor())', + }, + { operation: 'read', anonymous: true, where: '.status == "open"' }, + ]), + // Acting users who may not write the newsroom, or who aren't users. + rule(COMMENT, [ + { operation: 'create', anonymous: true, actingUser: 'reader' }, + ]), + rule(NOTE, [ + { operation: 'create', anonymous: true, actingUser: 'bogus' }, + ]), + rule(TIP, [ + { operation: 'create', anonymous: true, actingUser: 'missing' }, + ]), + ], + }, + meta: { adoptsFrom: REALM_POLICY }, + }, +}); + +module(basename(import.meta.filename), function (hooks) { + let newsroom: 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(NEWSROOM), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Newsroom', + policy: NEWSROOM_POLICY, + config: NEWSROOM_CONFIG, + }), + 'newsroom.gts': NEWSROOM_MODULE, + 'articles/open.json': article('Polls open', 'open', [SUBMITTER]), + 'articles/closed.json': article('Polls close', 'closed'), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + [READER]: ['read'], + [ORG_ADMIN]: ['read'], + }, + }, + { + realmURL: new URL(ORG), + fileSystem: { + 'realm.json': realmConfigCardJSON({ name: 'Org' }), + 'policies/newsroom.json': POLICY, + }, + permissions: { [ORG_ADMIN]: ['read', 'write', 'realm-owner'] }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + server = result.testRealmHttpServer; + request = supertest(server); + newsroom = result.realms.find((realm) => realm.url === NEWSROOM)!; + org = result.realms.find((realm) => realm.url === ORG)!; + } + + async function stop() { + for (let realm of [newsroom, 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 newCard(name: string, attributes: Record) { + return { + data: { + type: 'card', + attributes, + meta: { adoptsFrom: adoptsFrom(name) }, + }, + }; + } + + function createCard(name: string, attributes: Record) { + return request + .post(new URL(NEWSROOM).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(JSON.stringify(newCard(name, attributes))); + } + + function patchArticle(url: string, headline: string, status = 'open') { + return request + .patch(new URL(url).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: { headline, status }, + meta: { adoptsFrom: adoptsFrom('Article') }, + }, + }), + ); + } + + function operations(...entries: Record[]) { + return request + .post(`${new URL(NEWSROOM).pathname}_operations`) + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set('X-Forwarded-For', VISITOR) + .send(JSON.stringify({ 'boxel:operations': entries })); + } + + function createEntry(name: string, attributes: Record) { + return { + op: 'invoke', + 'boxel:name': 'create', + data: newCard(name, attributes).data, + }; + } + + async function stored(url: string) { + let content = await newsroom.operationCore.readFileAsText( + `${new URL(url).pathname.slice(new URL(NEWSROOM).pathname.length)}.json` as LocalPath, + ); + return content === undefined + ? undefined + : ( + JSON.parse(content) as { + data: { attributes: Record }; + } + ).data.attributes; + } + + // How many feedback cards the newsroom holds, as its editor searches it. + async function feedbackCount() { + await newsroom.indexing(); + let response = await request + .post(`${new URL(NEWSROOM).pathname}_search`) + .set('Accept', SupportedMimeType.CardJson) + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set( + 'Authorization', + `Bearer ${createJWT(newsroom, EDITOR, ['read', 'write', 'realm-owner'])}`, + ) + .send({ filter: { 'item.on': FEEDBACK } }); + if (response.status !== 200) { + throw new Error(`search answered ${response.status}: ${response.text}`); + } + return (response.body as { data: unknown[] }).data.length; + } + + async function setNewsroom(fields: { + config?: Record; + anonymousRateLimit?: unknown; + }) { + await newsroom.write( + 'realm.json', + realmConfigCardJSON({ + name: 'Newsroom', + policy: NEWSROOM_POLICY, + config: NEWSROOM_CONFIG, + ...fields, + }), + ); + await newsroom.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`, + ); + } + + test('a caller who is not signed in leaves feedback, written as the user the realm names', async function (assert) { + let response = await createCard('Feedback', { message: 'Great story' }); + assert.strictEqual(response.status, 201, response.text); + let id = (response.body as { data: { id: string } }).data.id; + assert.true(id.startsWith(NEWSROOM), 'the card is in the newsroom'); + assert.deepEqual( + await stored(id), + { message: 'Great story' }, + 'the card holds what was sent', + ); + let [record] = records.filter((r) => r.outcome === 'admitted'); + assert.deepEqual(record?.actingUsers, [SUBMITTER], 'made as the submitter'); + assert.strictEqual(record?.operation, 'create'); + assert.strictEqual(record?.clientIP, VISITOR); + }); + + test('an acting user who may not write the realm, or who is not a user, admits nothing', async function (assert) { + for (let [name, failure] of [ + ['Comment', 'no-write'], + ['Note', 'not-a-matrix-id'], + ['Tip', 'key-missing'], + ] as const) { + records = []; + let response = await createCard(name, { message: 'Hello' }); + unauthenticated(response, name, assert); + let refused = records.find((r) => r.outcome === 'refused'); + assert.deepEqual( + refused?.actingUserFailures?.map((f) => f.failure), + [failure], + `${name}: recorded as ${failure}`, + ); + } + }); + + test("a change to the realm's config takes effect on the next write", async function (assert) { + assert.strictEqual( + (await createCard('Feedback', { message: 'First' })).status, + 201, + ); + await setNewsroom({ config: { ...NEWSROOM_CONFIG, submitter: READER } }); + unauthenticated( + await createCard('Feedback', { message: 'Second' }), + 'the key now names a reader', + assert, + ); + await setNewsroom({ config: { reader: READER } }); + unauthenticated( + await createCard('Feedback', { message: 'Third' }), + 'the key is gone', + assert, + ); + assert.strictEqual(await feedbackCount(), 1, 'only the first was written'); + }); + + test('an update is admitted only where the grant’s condition holds, and every refusal is the same 401', async function (assert) { + let open = await patchArticle(OPEN_DRAFT, 'Polls open late'); + assert.strictEqual(open.status, 200, open.text); + assert.strictEqual((await stored(OPEN_DRAFT))?.headline, 'Polls open late'); + let closed = await patchArticle(CLOSED_DRAFT, 'Rewritten'); + let missing = await patchArticle(MISSING, 'Rewritten'); + unauthenticated(closed, 'a closed draft', assert); + unauthenticated(missing, 'a missing draft', assert); + assert.strictEqual(closed.text, missing.text, 'byte-identical'); + assert.strictEqual( + (await stored(CLOSED_DRAFT))?.headline, + 'Polls close', + 'nothing was written', + ); + }); + + test('a grant that reads the caller never admits such a caller, even to a card naming its acting user', async function (assert) { + let response = await request + .delete(new URL(OPEN_DRAFT).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR); + unauthenticated(response, 'a delete', assert); + assert.ok(await stored(OPEN_DRAFT), 'the draft is still there'); + }); + + test('a write over the limit gets 429 and writes nothing', async function (assert) { + await setNewsroom({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + assert.strictEqual( + (await createCard('Feedback', { message: 'One' })).status, + 201, + ); + let over = await createCard('Feedback', { message: 'Two' }); + 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 try again'); + assert.strictEqual(await feedbackCount(), 1, 'the second was not written'); + }); + + test('a refused write costs nothing', async function (assert) { + await setNewsroom({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + for (let i = 0; i < 3; i++) { + unauthenticated( + await patchArticle(CLOSED_DRAFT, `Try ${i}`), + `refused ${i}`, + assert, + ); + } + assert.strictEqual( + (await createCard('Feedback', { message: 'Still fits' })).status, + 201, + 'the budget is untouched', + ); + }); + + test('a batch is counted one unit per entry, and one that does not fit writes nothing', async function (assert) { + await setNewsroom({ + anonymousRateLimit: { requests: 2, windowSeconds: 600 }, + }); + let three = await operations( + createEntry('Feedback', { message: 'A' }), + createEntry('Feedback', { message: 'B' }), + createEntry('Feedback', { message: 'C' }), + ); + assert.strictEqual(three.status, 429, three.text); + assert.ok(three.headers['retry-after'], 'says when to try again'); + assert.strictEqual(await feedbackCount(), 0, 'nothing was written'); + let two = await operations( + createEntry('Feedback', { message: 'A' }), + createEntry('Feedback', { message: 'B' }), + ); + assert.strictEqual(two.status, 200, two.text); + assert.strictEqual(await feedbackCount(), 2, 'both were written'); + let [record] = records.filter((r) => r.outcome === 'admitted'); + assert.strictEqual(record?.cost, 2, 'counted as two'); + assert.deepEqual(record?.actingUsers, [SUBMITTER]); + }); + + test('a create posted to a subdirectory is told to authenticate, not that nothing is there', async function (assert) { + let response = await request + .post(`${new URL(NEWSROOM).pathname}elsewhere/`) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(JSON.stringify(newCard('Feedback', { message: 'Hi' }))); + unauthenticated(response, 'a subdirectory', assert); + }); + + test('a capability check answers such a caller about writes as the gate would', async function (assert) { + let response = await request + .post(`${new URL(NEWSROOM).pathname}_capabilities`) + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', SupportedMimeType.JSON) + .set('X-Forwarded-For', VISITOR) + .send({ + checks: [ + { target: FEEDBACK, operation: 'create' }, + { target: COMMENT, operation: 'create' }, + { target: OPEN_DRAFT, operation: 'update' }, + { target: CLOSED_DRAFT, operation: 'update' }, + ], + }); + assert.strictEqual(response.status, 200, response.text); + let allowed = (response.body.checks as { allowed: boolean }[]).map( + (check) => check.allowed, + ); + assert.deepEqual(allowed, [true, false, true, false]); + }); + + test('explain judges such a caller’s 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': [ + { + op: 'invoke', + 'boxel:name': 'explain', + href: NEWSROOM_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_DRAFT, 'update')).decision, + 'allowed', + 'an open draft', + ); + assert.strictEqual( + (await explain(CLOSED_DRAFT, 'update')).decision, + 'denied', + 'a closed draft', + ); + }); + + test('signed-in callers write as themselves, untouched by acting users', async function (assert) { + let response = await request + .patch(new URL(CLOSED_DRAFT).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .set( + 'Authorization', + `Bearer ${createJWT(newsroom, EDITOR, ['read', 'write', 'realm-owner'])}`, + ) + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: { headline: 'Edited', status: 'closed' }, + meta: { adoptsFrom: adoptsFrom('Article') }, + }, + }), + ); + assert.strictEqual(response.status, 200, response.text); + assert.deepEqual(records, [], 'and is no anonymous caller'); + }); +}); diff --git a/packages/runtime-common/card-operations/acting-users.ts b/packages/runtime-common/card-operations/acting-users.ts new file mode 100644 index 00000000000..80a6940806f --- /dev/null +++ b/packages/runtime-common/card-operations/acting-users.ts @@ -0,0 +1,78 @@ +// Who a request that authenticated nobody writes as. +// +// A grant that opts a write in to callers who aren't signed in names a key in +// the governed realm's `realm.json` `config` (`CompiledOperationGrant +// .anonymous.actingUserKey`), and the user that key holds is who the write is +// made as: the index job it starts, and every record of it, name that user. +// The key is read when the write is decided, not when the policy compiles, so +// changing the config, removing the key, or the user losing write takes effect +// on the next write, without the policy card changing. +// +// A grant whose key doesn't resolve to a Matrix user who may write the realm +// admits nothing. The caller is refused as though the grant weren't there, +// rather than let through as nobody, and why is kept for the request's records. + +// What a grant's acting-user key resolves to: the user, or why no write may be +// made as anyone through it. +export type ActingUserResolution = + | { user: string } + | { failure: ActingUserFailure }; + +// - `key-missing`: the realm's config holds no string under the key. +// - `not-a-matrix-id`: it holds something that isn't a Matrix user id. +// - `no-write`: the user it names may not write the realm. +export type ActingUserFailure = 'key-missing' | 'not-a-matrix-id' | 'no-write'; + +// The acting users of one request, resolved once per key however many of its +// writes name it, and the users its admitted writes were made as, in the order +// they were admitted. +export class ActingUsers { + #resolve: (key: string) => Promise; + #resolved = new Map>(); + #admitted: string[] = []; + #failures: { key: string; failure: ActingUserFailure }[] = []; + + constructor(resolve: (key: string) => Promise) { + this.#resolve = resolve; + } + + async resolve(key: string): Promise { + let resolved = this.#resolved.get(key); + if (!resolved) { + resolved = this.#resolve(key).then((resolution) => { + if ('failure' in resolution) { + this.#failures.push({ key, failure: resolution.failure }); + } + return resolution; + }); + this.#resolved.set(key, resolved); + } + return await resolved; + } + + // Records that a write was admitted through a grant naming `key`. + async admittedThrough(key: string): Promise { + let resolution = await this.resolve(key); + if ('user' in resolution) { + this.#admitted.push(resolution.user); + } + } + + // The users the request's admitted writes were made as, first admitted + // first, each once. + get admitted(): string[] { + return [...new Set(this.#admitted)]; + } + + // Why grants that would otherwise have admitted one of the request's + // writes didn't, each key once. + get failures(): readonly { key: string; failure: ActingUserFailure }[] { + return this.#failures; + } +} + +// Unresolvable: a realm that supplies no way to resolve acting users admits no +// write by a caller who isn't signed in. +export const NO_ACTING_USER = async (): Promise => ({ + failure: 'key-missing', +}); diff --git a/packages/runtime-common/card-operations/coordinator.ts b/packages/runtime-common/card-operations/coordinator.ts index c2592095f60..c9839f7e2b4 100644 --- a/packages/runtime-common/card-operations/coordinator.ts +++ b/packages/runtime-common/card-operations/coordinator.ts @@ -241,6 +241,18 @@ export interface CommitBatchOptions { // The invoking actor, as the identity `actor()` resolves to. It comes from // the authenticated realm user the request's permission check verified. actor?: string; + // Who a batch by a caller who isn't signed in is made as, once its entries + // are admitted: the acting user the first admitted write's grant names + // (see `ActingUsers`). It is who the batch's index job is initiated by. It + // never stands for the caller in a program, where `actor()` refuses for + // want of one. + actingUser?: () => string | undefined; + // Runs once every entry has been admitted and staged, and before anything + // is written. A throw refuses the batch whole, with nothing written, and + // its status is what the caller answers with. It is where a caller who + // isn't signed in is counted against their address's budget: only a batch + // that would otherwise commit costs them anything. + beforeCommit?: () => Promise; // Whether to return only once the batch's index job has landed. Waiting is // the default: a caller that reports a version and a generation per entry // needs the generation, and a caller reading the cards back needs the rows. @@ -487,6 +499,7 @@ export async function commitBatch( } finally { timings?.add('stage', Date.now() - stageStart); } + await opts.beforeCommit?.(); // Everything above either produced bytes for every entry or threw, and a // throw leaves the realm as it was. // @@ -2391,7 +2404,7 @@ async function commitStaged( // The batch's index job is tagged with the user whose request produced // it, the same as every other write path, so a reader draining its own // writes waits for this job rather than returning ahead of it. - initiatingUser: opts.actor ?? null, + initiatingUser: opts.actor ?? opts.actingUser?.() ?? null, ...(stageCursor ? { stageCursor } : {}), onDurable: releaseLocks, }, diff --git a/packages/runtime-common/card-operations/dispatch.ts b/packages/runtime-common/card-operations/dispatch.ts index 31bf6346314..0215225be09 100644 --- a/packages/runtime-common/card-operations/dispatch.ts +++ b/packages/runtime-common/card-operations/dispatch.ts @@ -26,6 +26,7 @@ import { type LoadedPolicy, type OperationPolicyAccess, } from './gate.ts'; +import { ActingUsers, NO_ACTING_USER } from './acting-users.ts'; import type { GateTrace } from './gate-trace.ts'; import { INTERNAL_ROUTE, type PolicyRoute } from './telemetry.ts'; import { explainOperation, type TargetRealm } from './explain.ts'; @@ -374,6 +375,10 @@ export interface OperationScope { // The request surface and route this invocation arrived on, which the // gate's decision record names (see `PolicyDecisionEvent`). readonly route: PolicyRoute; + // Who the request's writes are made as, where it authenticated nobody and + // a grant naming an acting user admits them (see `ActingUsers`). One for the + // request, shared by every scope derived from this one. + readonly actingUsers: ActingUsers; // The archived realm's refusal, where the realm holds one for this caller // (see `OperationRequest.seal`). `resolveOperation` answers with it what the // gate grants. @@ -412,6 +417,10 @@ export interface ScopeInvocation { seal?: Error; advisory?: boolean; route?: PolicyRoute; + // The request's acting users, where its caller supplies the one its + // records read from. Otherwise the scope resolves them through the realm's + // policy access. + actingUsers?: ActingUsers; } // What the realm ACL declined for a request, judged per invocation rather than @@ -435,6 +444,11 @@ export function newOperationScope( invocation: ScopeInvocation = {}, ): OperationScope { let rows = new Map>(); + let actingUsers = + invocation.actingUsers ?? + new ActingUsers( + (key) => core.policy?.actingUser?.(key) ?? NO_ACTING_USER(), + ); let peekInstance = (url: URL) => { let cached = rows.get(url.href); if (!cached) { @@ -463,6 +477,7 @@ export function newOperationScope( seal, advisory, route, + actingUsers, derive: (next) => scopeFor( next.caller ?? caller, diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index 8b56f8c54c1..106bd70d7e4 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -43,7 +43,6 @@ import { isOperationFailure, isWrite, refusalForNonReader, - type BaseOperation, type ExplainedGrantOutcome, type ExplainedIndexLag, type ExplainedRule, @@ -648,11 +647,6 @@ async function opensToAnonymous( core: OperationCore, operation: string, ): Promise { - // No route admits a caller who isn't signed in to a write, whatever the - // policy opens, so explain answers such a caller's write as the route does. - if (isWrite(operation as BaseOperation)) { - return false; - } let policy = await core.policy?.compiledPolicy(); return ( !policy?.uncompilable && diff --git a/packages/runtime-common/card-operations/gate.ts b/packages/runtime-common/card-operations/gate.ts index deaac8124c0..8cffe7e7c62 100644 --- a/packages/runtime-common/card-operations/gate.ts +++ b/packages/runtime-common/card-operations/gate.ts @@ -13,6 +13,7 @@ import { routesForField } from '../searchable-routes.ts'; import { chainType } from './adoption-chain.ts'; import { localPathFor, pathsFor } from './dispatch.ts'; import type { OperationCore, OperationScope } from './dispatch.ts'; +import type { ActingUserResolution } from './acting-users.ts'; import type { AdmissionSubject, BxlMutationModule } from './executors.ts'; import type { GateTrace, @@ -183,6 +184,11 @@ export interface OperationPolicyAccess { card: string, document: Record, ): Promise<{ compiled: CompiledRealmPolicy; reads: string[] }>; + // Who an acting-user key names in the realm's current `realm.json` + // `config`, and whether that user may write the realm (see + // `ActingUsers`). A realm without it admits no write by a caller who isn't + // signed in. + actingUser?(key: string): Promise; } // The target as the gate judges it. It holds what the realm resolved, and @@ -213,7 +219,13 @@ export type GateSubject = // trace the gate records into. export type GateScope = Pick< OperationScope, - 'caller' | 'coarseDeclined' | 'peekInstance' | 'trace' | 'advisory' | 'route' + | 'caller' + | 'coarseDeclined' + | 'peekInstance' + | 'trace' + | 'advisory' + | 'route' + | 'actingUsers' >; // The gate's refusal. It carries nothing, since what a refusal says is the @@ -626,6 +638,12 @@ async function decide( // anyone at all through one would widen it past what its author wrote. if (scope.caller.kind === 'anonymous') { matched = 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. + if (isWrite(base)) { + matched = await withActingUsers(scope, matched); + } } if (matched.length === 0) { return refuse('no-grant'); @@ -675,6 +693,7 @@ async function decide( let matchedOn: MatchedOn = { base, targetType: types[0] }; let unconditional = matched.find(({ grant }) => !grant.where); if (unconditional) { + await admittedAs(scope, unconditional); return { kind: 'granted', grant: unconditional, matchedOn, ...lockCheck }; } if (isWrite(base)) { @@ -1199,6 +1218,36 @@ export async function dischargePendingDecision( if (!('grant' in admission)) { throw gateRefusal(core, admission, pending.target, pending.name); } + await admittedAs(scope, admission); +} + +// The grants among `matched` whose acting user resolves to one who may write +// the realm. A grant that opts a write in to callers who aren't signed in +// always names a key (`anonymous-write-without-acting-user` otherwise), so one +// that names none admits nothing here. +async function withActingUsers( + scope: GateScope, + matched: MatchedGrant[], +): Promise { + let resolved = await Promise.all( + matched.map(async (candidate) => { + let key = candidate.grant.anonymous?.actingUserKey; + return key && 'user' in (await scope.actingUsers.resolve(key)); + }), + ); + return matched.filter((_candidate, index) => resolved[index]); +} + +// Records the user a write by a caller who isn't signed in is made as, once +// a grant has admitted it. +async function admittedAs( + scope: GateScope, + { grant }: MatchedGrant, +): Promise { + let key = grant.anonymous?.actingUserKey; + if (scope.caller.kind === 'anonymous' && key) { + await scope.actingUsers.admittedThrough(key); + } } // The write lock's decision record, naming what the gate matched the write on. diff --git a/packages/runtime-common/card-operations/index.ts b/packages/runtime-common/card-operations/index.ts index ceed240defe..ddee16cba04 100644 --- a/packages/runtime-common/card-operations/index.ts +++ b/packages/runtime-common/card-operations/index.ts @@ -1,3 +1,8 @@ +export { + ActingUsers, + type ActingUserFailure, + type ActingUserResolution, +} from './acting-users.ts'; export { lowerOperationDeclarations } from './lowering.ts'; export { noteRealmIndexMoved, diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index c4c6cb17f9f..6dc17753dc0 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -1,3 +1,4 @@ +import type { ActingUserFailure } from './acting-users.ts'; import { logger } from '../log.ts'; import type { BaseOperation, OperationErrorCode } from './types.ts'; @@ -551,6 +552,12 @@ export interface AnonymousRequestEvent { // How many units the request was counted as, where it was more than one: // a capability check counts one for each pair it asks about. cost?: number; + // For a write, the users it was made as: the acting user each admitting + // grant names, first admitted first. + actingUsers?: string[]; + // For a write a grant would otherwise have admitted, why its acting-user + // key named no one who may write the realm. + actingUserFailures?: { key: string; failure: ActingUserFailure }[]; retryAfterSeconds?: number; correlationId: string | null; } diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index eea198528c9..5862a3c8231 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -384,7 +384,12 @@ import { import type { BatchCore, BatchEntryResult, + CommitBatchOptions, } from './card-operations/coordinator.ts'; +import { + ActingUsers, + type ActingUserResolution, +} from './card-operations/acting-users.ts'; import type { BatchEntry } from './card-operations/executors.ts'; import type { DeferredPrerenderHtml, @@ -474,6 +479,7 @@ import { type MatrixClient, ensureFullMatrixUserId, getMatrixUsername, + isMatrixUserId, } from './matrix-client.ts'; import { PACKAGES_FAKE_ORIGIN } from './package-shim-handler.ts'; @@ -2103,9 +2109,10 @@ export interface WriteOptions { // (post assume-user) that `checkPermission` records on the request context // as `authenticatedUser`. Tags the resulting incremental index job so read // endpoints can scope their read-your-writes drain to this user's own - // reads. Absent for system-originated writes and for credential-less - // writes, whose jobs no reader waits on (anonymous writes are unsupported; - // see `drainRequestersOwnIndexing`). + // reads. Absent for system-originated writes. A write by a caller who isn't + // signed in is initiated by the acting user it is made as (see + // `CommitBatchOptions.actingUser`), whose job no reader waits on: such a + // caller has no read-your-writes claim (see `drainRequestersOwnIndexing`). initiatingUser?: string | null; // Where a caller reporting where its write's time went wants this commit's // stages stamped. The commit drains prior indexing, makes the bytes @@ -2297,9 +2304,10 @@ export type RequestContext = { // compares, for a caller the ACL already declined. authenticatedUser?: string; // Set by `checkPermission` when the request presented no Authorization - // header at all. Anonymous writes are unsupported (no realm grants `*` - // write in practice), so a provably credential-less caller has no - // read-your-writes claim and the read gate skips them outright — unlike a + // header at all. A provably credential-less caller has no read-your-writes + // claim, since a write a policy admits for one is made as an acting user it + // holds no session for (see `ActingUsers`), so the read gate skips them + // outright — unlike a // caller whose identity is merely unknown: a token that failed // verification, an assume-user indirection the public path cannot // validate, or a realm-internal dispatch that never ran `checkPermission`, @@ -2312,6 +2320,9 @@ export type RequestContext = { // counted against the realm's anonymous rate limit once what it asked for // succeeds, and is told to authenticate wherever no grant admits it. anonymousCaller?: AnonymousCaller; + // Who such a caller's writes are made as (see `ActingUsers`), resolved once + // for the request however many of its operations ask. + actingUsers?: ActingUsers; // The user this request's session vouches for as themselves, not just as // an identity: a token `checkPermission` verified end to end, whose session // is not revoked, which is not delegated to one realm, and which stands for @@ -2377,16 +2388,23 @@ interface RequestDispatch { // 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 whether a successful answer counts against the - // realm's anonymous rate limit. A dispatch without it never admits a - // caller who isn't signed in. + // 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. anonymous?: AnonymousDispatch; handle: () => Promise; } interface AnonymousDispatch { operations: readonly string[]; - charged: boolean; + // - `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 + // anything that cost would pay for: a capability check per pair, a write + // per entry once the whole batch is admitted. + // - `never`: counts for nothing on its own. + // Every request counted at all is turned away at admission once its + // address has no budget left. + counted: 'once-served' | 'by-handler' | 'never'; } // A request the realm's policy admitted though it authenticated nobody. @@ -2400,7 +2418,10 @@ export interface AnonymousCaller { // The caller is one of the platform's own services: counted in telemetry, // never limited or blocked. infra: boolean; - charged: boolean; + counted: AnonymousDispatch['counted']; + // How long the caller is told to wait, where the request was refused + // because it couldn't be counted. + retryAfterSeconds?: number; // Set where the request serves what it asked for: a card's document or // headers, or a file's bytes. Only such a request is counted, so a redirect // or a refusal costs the caller nothing whatever its status. @@ -2419,9 +2440,43 @@ export type AnonymousCount = | { kind: 'rate-limited'; retryAfterSeconds: number } | { kind: 'unavailable' }; +// What a realm with a policy tells a request that authenticated nobody, wherever +// nothing admits it: to authenticate. +const AUTHENTICATION_REQUIRED: OperationError = { + status: 401, + code: 'actor-required', + title: 'Authentication required', + detail: AuthenticationErrorMessages.MissingAuthHeader, +}; + // How long a caller the realm couldn't count is told to wait. const COUNT_UNAVAILABLE_RETRY_SECONDS = 5; +// The refusal a caller who isn't signed in gets where their request wasn't +// counted: over the realm's limit, or the count couldn't be read. Nothing was +// done either way, and `meta.retryAfterSeconds` says when to try again. +function anonymousCountRefusal( + count: Exclude, +): OperationError { + if (count.kind === 'rate-limited') { + let { retryAfterSeconds } = count; + return { + status: 429, + code: 'rate-limited', + title: 'Too many requests', + detail: `This address has made as many requests to this realm without signing in as it allows for now. Try again in ${retryAfterSeconds} second${retryAfterSeconds === 1 ? '' : 's'}.`, + meta: { retryAfterSeconds }, + }; + } + return { + status: 503, + code: 'rate-limit-unavailable', + title: 'Service unavailable', + detail: `This realm couldn't count this request, so it wasn't carried out. Try again in ${COUNT_UNAVAILABLE_RETRY_SECONDS} seconds.`, + meta: { retryAfterSeconds: COUNT_UNAVAILABLE_RETRY_SECONDS }, + }; +} + // Releases a response that is answered in place of, so a file stream it holds // is closed rather than left open. async function discardBody(response: ResponseWithNodeStream): Promise { @@ -2432,25 +2487,25 @@ async function discardBody(response: ResponseWithNodeStream): Promise { // The card+json read: what an anonymous grant on `read` opens. const ANONYMOUS_CARD_READ: AnonymousDispatch = { operations: ['read'], - charged: true, + counted: 'once-served', }; // A search of the realm: what a `query` grant opens. It is counted once its // grants have scoped it, whatever rows it finds. const ANONYMOUS_SEARCH: AnonymousDispatch = { operations: ['query'], - charged: true, + counted: 'once-served', }; // A data file's or a card's stored bytes: what a `readSource` grant opens. const ANONYMOUS_BYTES_READ: AnonymousDispatch = { operations: ['readSource'], - charged: true, + counted: 'once-served', }; // A stylesheet goes with the markup that references it, so it is served to an // anonymous caller a policy would serve any card's markup to, and counts for // nothing on its own: the read or search that drew the markup was counted. const ANONYMOUS_STYLESHEET: AnonymousDispatch = { operations: ['read', 'query'], - charged: false, + counted: 'never', }; // A capability check asks what the caller could invoke and invokes nothing, so // it answers an anonymous caller in any realm whose policy opens anything to @@ -2458,7 +2513,30 @@ const ANONYMOUS_STYLESHEET: AnonymousDispatch = { // before any is checked (see `handleCapabilities`). const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { operations: [], - charged: false, + counted: 'by-handler', +}; +// A card+json write, made as the user the admitting grant names. Counted one +// unit once the write is admitted and before it is made (see +// `#anonymousCommit`). +const ANONYMOUS_CARD_CREATE: AnonymousDispatch = { + operations: ['create'], + counted: 'by-handler', +}; +const ANONYMOUS_CARD_UPDATE: AnonymousDispatch = { + operations: ['update'], + counted: 'by-handler', +}; +const ANONYMOUS_CARD_DELETE: AnonymousDispatch = { + operations: ['delete'], + counted: 'by-handler', +}; +// The operations envelope runs whatever its entries name, each judged by the +// gate on its own, so it admits a caller wherever the policy opens anything to +// one. A batch that writes is counted one unit per entry once every entry is +// admitted, and one that only reads, one unit per entry before it runs. +const ANONYMOUS_OPERATIONS: AnonymousDispatch = { + operations: [], + counted: 'by-handler', }; // A route as `Realm.routeDescriptions` lists it: a router route, the hashed @@ -2493,8 +2571,13 @@ interface CardWriteAdmission { entry: { admit?: (judged: AdmissionSubject | undefined) => Promise }; // How the write's batch is committed for this caller (see `#mintPosture`). // Every card+json write commits under it, so for a caller who may not read - // the realm, the realm mints the ids of any card the write creates. - batch: { mintIds?: true }; + // the realm, the realm mints the ids of any card the write creates, and a + // caller who isn't signed in writes as its grant's acting user and is + // counted once the write is admitted (see `#anonymousCommit`). + batch: { mintIds?: true } & Pick< + CommitBatchOptions, + 'actingUser' | 'beforeCommit' + >; // Refuses, by throwing, a document whose side-loads a write the gate // admitted would stage. assertSideLoads(included: readonly unknown[] | undefined): void; @@ -3102,25 +3185,25 @@ export class Realm { '/_operations', SupportedMimeType.BoxelOperations, this.handleOperations.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_OPERATIONS }, ) .query( '/_operations', SupportedMimeType.BoxelOperations, this.handleOperations.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_OPERATIONS }, ) .post( '/_operations', SupportedMimeType.JSONAPI, this.handleOperations.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_OPERATIONS }, ) .query( '/_operations', SupportedMimeType.JSONAPI, this.handleOperations.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_OPERATIONS }, ) // What the policy gate would decide, asked ahead of the call, so a view // can hide a control its caller may not use rather than render every @@ -3161,7 +3244,7 @@ export class Realm { '(/|/.+/)', SupportedMimeType.CardJson, this.createCard.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_CARD_CREATE }, ) .get('/.*', SupportedMimeType.CardJson, this.getCard.bind(this), { ...APPLIES_ARCHIVED_SEAL, @@ -3194,13 +3277,13 @@ export class Realm { '/.+(? isWrite(definition.base), ); + // A caller who isn't signed in is counted one unit per entry, once every + // entry is admitted: a batch that writes, as its commit begins, and one + // that only reads, once its reads have run. A batch that doesn't fit is + // refused whole, with nothing written. + let anonymousCommit = this.#anonymousCommit( + request, + requestContext, + scope, + () => entries.length, + ); + if (writes.length === 0) { + await anonymousCommit.beforeCommit?.(); + } if (writes.length > 0) { // Each write in request order, as the coordinator stages it, keyed by // the position it was sent under (see `#stagedWrite`). @@ -6226,6 +6323,7 @@ export class Realm { // for, and the only front door that produces it. reportAuthorship: true, ...this.#mintPosture(coarseDeclined), + ...anonymousCommit, }, ); // The coordinator answers in the flat order of the entries it staged, @@ -6717,7 +6815,13 @@ export class Realm { return { coarseDeclined: readAllowed ? 'none' : 'all' }; } let coarseDeclined: CoarseDeclined = readAllowed ? 'writes' : 'all'; - if (await this.#policyJudges(write.refusal, requestContext)) { + // A caller the realm admitted without a session writes as the grant's + // acting user, so the gate judges their writes as it judges a reader's: + // what a grant opts in to them, made as a user who may write the realm. + if ( + (await this.#policyJudges(write.refusal, requestContext)) || + requestContext.anonymousCaller + ) { return { coarseDeclined }; } return { @@ -7031,6 +7135,7 @@ export class Realm { document, this.#policyCompileEnvironment(), ), + actingUser: (key) => this.#resolveActingUser(key), }, targetRealm: (href) => this.#targetRealm(href), // Compiled as this realm's own policy cache compiles the card its @@ -7303,9 +7408,48 @@ export class Realm { error: OperationError, requestContext: RequestContext, ): OperationError { - return this.#coarseDeclined(requestContext) === 'all' - ? refusalForNonReader(error) - : error; + if (this.#coarseDeclined(requestContext) !== 'all') { + return error; + } + let seen = refusalForNonReader(error); + // A caller who isn't signed in is told to authenticate wherever a caller + // who may not read the realm is told nothing is there, so the answer is + // the one every request that authenticated nobody gets. + return requestContext.anonymousCaller && seen.code === 'target-not-found' + ? AUTHENTICATION_REQUIRED + : seen; + } + + // Who `key` names in this realm's current `realm.json` `config`, and + // whether that user may write the realm, as the realm's ACL says now. + async #resolveActingUser(key: string): Promise { + let config = await this.getRealmConfig(); + let user = config?.[key]; + if (typeof user !== 'string' || user === '') { + return { failure: 'key-missing' }; + } + if (!isMatrixUserId(user)) { + return { failure: 'not-a-matrix-id' }; + } + let checker = new RealmPermissionChecker( + await fetchRealmPermissions(this.#dbAdapter, new URL(this.url)), + this.#matrixClient, + ); + return (await checker.can(user, 'write')) + ? { user } + : { failure: 'no-write' }; + } + + // The acting users of a request a caller who isn't signed in sent, one set + // for the request, which every scope it builds shares. + #actingUsersFor(requestContext: RequestContext): ActingUsers | undefined { + if (!requestContext.anonymousCaller) { + return undefined; + } + requestContext.actingUsers ??= new ActingUsers((key) => + this.#resolveActingUser(key), + ); + return requestContext.actingUsers; } // Who an operation dispatched from an HTTP request is running for. The actor @@ -8199,7 +8343,7 @@ export class Realm { clientIP: address ? formatIP(address) : null, rateLimitKey: address ? rateLimitKey(address) : undefined, infra: request.headers.get(CLIENT_CLASS_HEADER) === INFRA_CLIENT_CLASS, - charged: anonymous.charged, + counted: anonymous.counted, }; if (!caller.infra) { let access = await this.getAnonymousAccess(); @@ -8218,7 +8362,7 @@ export class Realm { }); return undefined; } - if (caller.charged) { + if (caller.counted !== 'never') { caller.turnedAway = await this.#checkAnonymousBudget( request, caller, @@ -8345,13 +8489,26 @@ export class Realm { if (!caller || caller.turnedAway) { return response; } + // A refusal raised inside a handler, such as a batch that didn't fit, + // carries when to try again in its body; the header says the same. + if ( + caller.retryAfterSeconds !== undefined && + (response.status === 429 || response.status === 503) + ) { + response.headers.set('Retry-After', String(caller.retryAfterSeconds)); + return response; + } if (!caller.served) { if (response.status === 401 || request.method === 'HEAD') { - this.#recordAnonymous(request, caller, { outcome: 'refused' }); + let failures = requestContext.actingUsers?.failures ?? []; + this.#recordAnonymous(request, caller, { + outcome: 'refused', + ...(failures.length > 0 ? { actingUserFailures: [...failures] } : {}), + }); } return response; } - if (!caller.charged) { + if (caller.counted !== 'once-served') { return response; } let answer = await this.#countAnonymous(request, requestContext, 1); @@ -8398,9 +8555,10 @@ export class Realm { request: Request, caller: AnonymousCaller, cost: number, + detail: Pick = {}, ): Promise { if (caller.infra) { - this.#recordAnonymous(request, caller, { outcome: 'infra' }); + this.#recordAnonymous(request, caller, { outcome: 'infra', ...detail }); return { kind: 'counted' }; } let { limit, limitFrom } = await this.getAnonymousAccess(); @@ -8424,6 +8582,7 @@ export class Realm { limit: recordedLimit, count: outcome.count, ...costDetail, + ...detail, }); return { kind: 'counted' }; } @@ -8445,52 +8604,34 @@ export class Realm { requestContext: RequestContext, retryAfterSeconds: number, ): ResponseWithNodeStream { - return createResponse({ - body: JSON.stringify( - errorsDocument({ - status: 429, - code: 'rate-limited', - title: 'Too many requests', - detail: `This address has made as many requests to this realm without signing in as it allows for now. Try again in ${retryAfterSeconds} second${retryAfterSeconds === 1 ? '' : 's'}.`, - meta: { retryAfterSeconds }, - }), - null, - 2, - ), - init: { - status: 429, - headers: { - 'content-type': SupportedMimeType.JSONAPI, - 'Retry-After': String(retryAfterSeconds), - 'X-Boxel-Realm-Url': requestContext.realm.url, - }, - }, + return this.#countRefusalResponse( requestContext, - }); + anonymousCountRefusal({ kind: 'rate-limited', retryAfterSeconds }), + ); } // The answer to a request from a caller who isn't signed in that the realm // couldn't count: nothing was done, and the caller may try again shortly. #countUnavailableResponse( requestContext: RequestContext, + ): ResponseWithNodeStream { + return this.#countRefusalResponse( + requestContext, + anonymousCountRefusal({ kind: 'unavailable' }), + ); + } + + #countRefusalResponse( + requestContext: RequestContext, + error: OperationError, ): ResponseWithNodeStream { return createResponse({ - body: JSON.stringify( - errorsDocument({ - status: 503, - code: 'rate-limit-unavailable', - title: 'Service unavailable', - detail: `This realm couldn't count this request, so it wasn't carried out. Try again in ${COUNT_UNAVAILABLE_RETRY_SECONDS} seconds.`, - meta: { retryAfterSeconds: COUNT_UNAVAILABLE_RETRY_SECONDS }, - }), - null, - 2, - ), + body: JSON.stringify(errorsDocument(error), null, 2), init: { - status: 503, + status: error.status, headers: { 'content-type': SupportedMimeType.JSONAPI, - 'Retry-After': String(COUNT_UNAVAILABLE_RETRY_SECONDS), + 'Retry-After': String(error.meta!.retryAfterSeconds), 'X-Boxel-Realm-Url': requestContext.realm.url, }, }, @@ -8498,6 +8639,36 @@ export class Realm { }); } + // What a batch by a caller the realm admitted without a session commits + // with: who it is made as, and its count. `cost` units are counted against + // the caller's budget once every entry is admitted and before anything is + // written, so a batch the gate refuses costs nothing, and one that doesn't + // fit is refused whole. Nothing for any other caller. + #anonymousCommit( + request: Request, + requestContext: RequestContext, + scope: OperationScope, + cost: () => number, + ): Pick { + let caller = requestContext.anonymousCaller; + if (!caller) { + return {}; + } + return { + actingUser: () => scope.actingUsers.admitted[0], + beforeCommit: async () => { + let counted = await this.#chargeCaller(request, caller, cost(), { + actingUsers: scope.actingUsers.admitted, + }); + if (counted.kind !== 'counted') { + let refusal = anonymousCountRefusal(counted); + caller.retryAfterSeconds = refusal.meta!.retryAfterSeconds as number; + throw new OperationFailure(refusal); + } + }, + }; + } + #recordAnonymous( request: Request, caller: AnonymousCaller, @@ -8509,6 +8680,8 @@ export class Realm { | 'count' | 'retryAfterSeconds' | 'cost' + | 'actingUsers' + | 'actingUserFailures' >, ): void { recordSafely('anonymous-request', () => @@ -8592,16 +8765,7 @@ export class Realm { // caller no grant admitted at all. #authenticationRequired(requestContext: RequestContext): Response { return createResponse({ - body: JSON.stringify( - errorsDocument({ - status: 401, - code: 'actor-required', - title: 'Authentication required', - detail: AuthenticationErrorMessages.MissingAuthHeader, - }), - null, - 2, - ), + body: JSON.stringify(errorsDocument(AUTHENTICATION_REQUIRED), null, 2), init: { status: 401, headers: { @@ -13064,6 +13228,7 @@ export class Realm { caller: scopeCallerFor(this.#callerOf(request, requestContext).actor), coarseDeclined, route: cardJsonRoute(request.method), + actingUsers: this.#actingUsersFor(requestContext), }); let decision = await resolveFacadeWrite(core, target, base, scope); let assertSideLoads = (included: readonly unknown[] | undefined) => { @@ -13093,7 +13258,10 @@ export class Realm { // lands: the realm mints its id, and the card lands beneath the realm's // root. That caller is told a refusal as a card that isn't there, so the // refusal here says no more than the gate's own. - let batch = this.#mintPosture(coarseDeclined); + let batch = { + ...this.#mintPosture(coarseDeclined), + ...this.#anonymousCommit(request, requestContext, scope, () => 1), + }; let assertDestination = (directory: string) => { if (batch.mintIds && directory !== '') { throw new OperationFailure({ diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index f876c44b7e6..efba31733cb 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -310,8 +310,8 @@ 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 whether a successful - // answer counts against the realm's anonymous rate limit. Only a route that + // open to such callers (empty 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. anonymous?: AnonymousRouteOptions; @@ -319,7 +319,7 @@ export interface RouteOptions { export interface AnonymousRouteOptions { operations: readonly string[]; - charged: boolean; + counted: 'once-served' | 'by-handler' | 'never'; } export interface Route { From 82b55d04e92419867f11f788dcc304740e10a3b6 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 13:45:30 -0400 Subject: [PATCH 2/5] Answer anonymous card-write refusals with their codes; refusals of admitted routes are recorded Co-Authored-By: Claude Opus 5.5 --- .../tests/realm-policy-anonymous-read-test.ts | 6 +++++- packages/runtime-common/realm.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts index bdbf7f6304e..57fbe9edeaf 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -857,6 +857,10 @@ module(basename(import.meta.filename), function (hooks) { for (let [label, send] of closed) { assert.strictEqual((await send()).status, 401, `${label}: 401`); } - assert.deepEqual(articleRecords(), [], 'and none of them is admitted'); + assert.deepEqual( + articleRecords().filter((r) => r.outcome !== 'refused'), + [], + 'and none of them is admitted', + ); }); }); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 5862a3c8231..a661649cf6b 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -13448,6 +13448,17 @@ export class Realm { if (status === 404) { return notFound(request, requestContext); } + // A caller who isn't signed in gets the answers every such request gets, + // with their codes: told to authenticate, or told when to try again. + if (error.code === 'actor-required') { + return this.#authenticationRequired(requestContext); + } + if ( + error.code === 'rate-limited' || + error.code === 'rate-limit-unavailable' + ) { + return this.#countRefusalResponse(requestContext, error); + } if (status === 400) { return badRequest({ message: detail, requestContext, ...identity }); } From 611d92da3bb3eca88cbd2349cef4fcf650b72068 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 13:57:26 -0400 Subject: [PATCH 3/5] Count an anonymous batch once and never for nothing; answer world-readable capability checks about anonymous writes Co-Authored-By: Claude Opus 5.5 --- .../realm-policy-anonymous-write-test.ts | 178 +++++++++++++++++- packages/runtime-common/realm.ts | 56 ++++-- 2 files changed, 215 insertions(+), 19 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-anonymous-write-test.ts b/packages/realm-server/tests/realm-policy-anonymous-write-test.ts index 2c10f869c47..31a03852d2d 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-write-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-write-test.ts @@ -36,6 +36,10 @@ import { setupCatalogTestSubset } from './helpers/catalog-test-subset.ts'; // `realm.json` names, and opts in a few writes whose acting users don't // resolve to anyone who may write the newsroom. const NEWSROOM = 'http://127.0.0.1:4444/newsroom/'; +// A board anyone may read, and only a signed-in writer may write without a +// grant; its policy lets visitors post feedback as its own submitter. +const BOARD = 'http://127.0.0.1:4444/board/'; +const BOARD_POLICY = 'http://127.0.0.1:4444/org/policies/board'; const ORG = 'http://127.0.0.1:4444/org/'; const NEWSROOM_POLICY = `${ORG}policies/newsroom`; const EDITOR = '@editor:localhost'; @@ -88,6 +92,7 @@ const NOTE = type('Note'); const TIP = type('Tip'); const OPEN_DRAFT = `${NEWSROOM}articles/open`; +const SPAM = `${NEWSROOM}articles/spam`; const CLOSED_DRAFT = `${NEWSROOM}articles/closed`; const MISSING = `${NEWSROOM}articles/nowhere`; @@ -138,6 +143,12 @@ const POLICY = JSON.stringify({ actingUser: 'submitter', where: '.authorIds | any(. == actor())', }, + { + operation: 'delete', + anonymous: true, + actingUser: 'submitter', + where: '.status == "spam"', + }, { operation: 'read', anonymous: true, where: '.status == "open"' }, ]), // Acting users who may not write the newsroom, or who aren't users. @@ -158,6 +169,7 @@ const POLICY = JSON.stringify({ module(basename(import.meta.filename), function (hooks) { let newsroom: Realm; + let board: Realm; let org: Realm; let request: SuperTest; let server: Server; @@ -190,6 +202,7 @@ module(basename(import.meta.filename), function (hooks) { 'newsroom.gts': NEWSROOM_MODULE, 'articles/open.json': article('Polls open', 'open', [SUBMITTER]), 'articles/closed.json': article('Polls close', 'closed'), + 'articles/spam.json': article('Buy now', 'spam'), }, permissions: { [EDITOR]: ['read', 'write', 'realm-owner'], @@ -198,11 +211,44 @@ module(basename(import.meta.filename), function (hooks) { [ORG_ADMIN]: ['read'], }, }, + { + realmURL: new URL(BOARD), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Board', + policy: BOARD_POLICY, + config: { submitter: SUBMITTER }, + }), + 'newsroom.gts': NEWSROOM_MODULE, + }, + permissions: { + '*': ['read'], + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + }, + }, { realmURL: new URL(ORG), fileSystem: { 'realm.json': realmConfigCardJSON({ name: 'Org' }), 'policies/newsroom.json': POLICY, + 'policies/board.json': JSON.stringify({ + data: { + type: 'card', + attributes: { + rules: [ + rule({ module: `${BOARD}newsroom`, name: 'Feedback' }, [ + { + operation: 'create', + anonymous: true, + actingUser: 'submitter', + }, + ]), + ], + }, + meta: { adoptsFrom: REALM_POLICY }, + }, + }), }, permissions: { [ORG_ADMIN]: ['read', 'write', 'realm-owner'] }, }, @@ -215,11 +261,12 @@ module(basename(import.meta.filename), function (hooks) { server = result.testRealmHttpServer; request = supertest(server); newsroom = result.realms.find((realm) => realm.url === NEWSROOM)!; + board = result.realms.find((realm) => realm.url === BOARD)!; org = result.realms.find((realm) => realm.url === ORG)!; } async function stop() { - for (let realm of [newsroom, org]) { + for (let realm of [newsroom, board, org]) { realm.__testOnlyClearCaches(); realm.unsubscribe(); } @@ -283,8 +330,18 @@ module(basename(import.meta.filename), function (hooks) { } function operations(...entries: Record[]) { - return request - .post(`${new URL(NEWSROOM).pathname}_operations`) + return operationsAs('POST', ...entries); + } + + function operationsAs( + method: 'POST' | 'QUERY', + ...entries: Record[] + ) { + let req = request.post(`${new URL(NEWSROOM).pathname}_operations`); + if (method === 'QUERY') { + req = req.set('X-HTTP-Method-Override', 'QUERY'); + } + return req .set('Accept', SupportedMimeType.BoxelOperations) .set('Content-Type', SupportedMimeType.BoxelOperations) .set('X-Forwarded-For', VISITOR) @@ -584,4 +641,119 @@ module(basename(import.meta.filename), function (hooks) { assert.strictEqual(response.status, 200, response.text); assert.deepEqual(records, [], 'and is no anonymous caller'); }); + + test('a create gets an id the realm mints, whatever id it names', async function (assert) { + let doc = newCard('Feedback', { message: 'Pick my id' }); + let response = await request + .post(new URL(NEWSROOM).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(JSON.stringify({ data: { ...doc.data, lid: 'chosen-by-me' } })); + assert.strictEqual(response.status, 201, response.text); + let id = (response.body as { data: { id: string } }).data.id; + assert.notOk(id.includes('chosen-by-me'), `minted: ${id}`); + }); + + test('a write that side-loads another card is refused, and nothing is written', async function (assert) { + let doc = newCard('Feedback', { message: 'With a friend' }); + let response = await request + .post(new URL(NEWSROOM).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send( + JSON.stringify({ + ...doc, + included: [ + { + type: 'card', + lid: 'friend', + attributes: { message: 'Side-loaded' }, + meta: { adoptsFrom: adoptsFrom('Feedback') }, + }, + ], + }), + ); + unauthenticated(response, 'a side-load', assert); + assert.strictEqual(await feedbackCount(), 0, 'nothing was written'); + }); + + test('an envelope update gives the same 401 for a closed draft and a missing one', async function (assert) { + let update = (href: string) => + operations({ + op: 'invoke', + 'boxel:name': 'update', + href, + data: { + type: 'card', + attributes: { headline: 'Rewritten', status: 'open' }, + meta: { adoptsFrom: adoptsFrom('Article') }, + }, + }); + let closed = await update(CLOSED_DRAFT); + let missing = await update(MISSING); + unauthenticated(closed, 'a closed draft', assert); + unauthenticated(missing, 'a missing draft', assert); + assert.strictEqual(closed.text, missing.text, 'byte-identical'); + }); + + test('a batch that only reads is counted one unit per entry once its reads have run', async function (assert) { + await setNewsroom({ + anonymousRateLimit: { requests: 2, windowSeconds: 600 }, + }); + let read = { op: 'invoke', 'boxel:name': 'read', href: OPEN_DRAFT }; + let two = await operationsAs('QUERY', read, read); + assert.strictEqual(two.status, 200, two.text); + let over = await operationsAs('QUERY', read); + assert.strictEqual(over.status, 429, 'the budget is spent'); + let [record] = records.filter((r) => r.outcome === 'admitted'); + assert.strictEqual(record?.cost, 2, 'counted as two'); + }); + + test('an empty batch is answered as it is for anyone, and costs nothing', async function (assert) { + let response = await operations(); + assert.strictEqual(response.status, 200, response.text); + assert.deepEqual( + records.filter((r) => r.outcome !== 'refused'), + [], + 'nothing was counted', + ); + }); + + test('a delete is admitted where its grant holds', async function (assert) { + let response = await request + .delete(new URL(SPAM).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR); + assert.true(response.status < 300, `${response.status} ${response.text}`); + assert.notOk(await stored(SPAM), 'the spam is gone'); + }); + + test('a realm anyone may read answers a capability check about a write it opens to such callers', async function (assert) { + let feedback = { module: `${BOARD}newsroom`, name: 'Feedback' }; + 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: feedback, operation: 'create' }] }); + assert.strictEqual(response.status, 200, response.text); + assert.true(response.body.checks[0].allowed, 'the form may be shown'); + let created = await request + .post(new URL(BOARD).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: { message: 'Hello board' }, + meta: { + adoptsFrom: { module: rri(`${BOARD}newsroom`), name: 'Feedback' }, + }, + }, + }), + ); + assert.strictEqual(created.status, 201, created.text); + assert.ok(board, 'the board realm is up'); + }); }); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index a661649cf6b..5c3242453e7 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -349,6 +349,7 @@ import { } from './card-operations/named-query.ts'; import { settledWithin, STAGING_WIDTH } from './card-operations/coordinator.ts'; import { + ANONYMOUS_ELIGIBLE_OPERATIONS, effectiveLinkStrategy, OperationFailure, isDocumentResult, @@ -2111,8 +2112,10 @@ export interface WriteOptions { // endpoints can scope their read-your-writes drain to this user's own // reads. Absent for system-originated writes. A write by a caller who isn't // signed in is initiated by the acting user it is made as (see - // `CommitBatchOptions.actingUser`), whose job no reader waits on: such a - // caller has no read-your-writes claim (see `drainRequestersOwnIndexing`). + // `CommitBatchOptions.actingUser`), so it shares that user's writer lane, + // and that user's reads wait for it as for their own writes. A realm's + // config names a dedicated account for that reason, not a person who edits + // the realm. initiatingUser?: string | null; // Where a caller reporting where its write's time went wants this commit's // stages stamped. The commit drains prior indexing, makes the bytes @@ -2530,6 +2533,12 @@ const ANONYMOUS_CARD_DELETE: AnonymousDispatch = { operations: ['delete'], counted: 'by-handler', }; +// 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. +const ANONYMOUS_WRITE_CHECK: AnonymousDispatch = { + operations: ANONYMOUS_ELIGIBLE_OPERATIONS.filter(isWrite), + counted: 'never', +}; // The operations envelope runs whatever its entries name, each judged by the // gate on its own, so it admits a caller wherever the policy opens anything to // one. A batch that writes is counted one unit per entry once every entry is @@ -6815,12 +6824,18 @@ export class Realm { return { coarseDeclined: readAllowed ? 'none' : 'all' }; } let coarseDeclined: CoarseDeclined = readAllowed ? 'writes' : 'all'; - // A caller the realm admitted without a session writes as the grant's - // acting user, so the gate judges their writes as it judges a reader's: - // what a grant opts in to them, made as a user who may write the realm. + // A caller who isn't signed in writes as the grant's acting user, so the + // gate judges their writes as it judges a reader's: what a grant opts in + // to them, made as a user who may write the realm. That holds whether the + // realm admitted them to this check through its policy or its ACL let + // anyone read it, as long as the policy opens a write to such callers and + // the realm doesn't refuse their address. if ( (await this.#policyJudges(write.refusal, requestContext)) || - requestContext.anonymousCaller + requestContext.anonymousCaller || + (write.refusal instanceof CoarseAuthenticationRequired && + !requestContext.authenticatedUser && + (await this.#anonymousCallerFor(request, ANONYMOUS_WRITE_CHECK))) ) { return { coarseDeclined }; } @@ -8654,18 +8669,27 @@ export class Realm { if (!caller) { return {}; } + // Counted once for the request, however many times its batch commits: a + // write the realm commits again to reserialize what it wrote is one write. + // A batch with nothing in it costs nothing. + let counting: Promise | undefined; + let count = async () => { + let units = cost(); + if (units < 1) { + return; + } + let counted = await this.#chargeCaller(request, caller, units, { + actingUsers: scope.actingUsers.admitted, + }); + if (counted.kind !== 'counted') { + let refusal = anonymousCountRefusal(counted); + caller.retryAfterSeconds = refusal.meta!.retryAfterSeconds as number; + throw new OperationFailure(refusal); + } + }; return { actingUser: () => scope.actingUsers.admitted[0], - beforeCommit: async () => { - let counted = await this.#chargeCaller(request, caller, cost(), { - actingUsers: scope.actingUsers.admitted, - }); - if (counted.kind !== 'counted') { - let refusal = anonymousCountRefusal(counted); - caller.retryAfterSeconds = refusal.meta!.retryAfterSeconds as number; - throw new OperationFailure(refusal); - } - }, + beforeCommit: () => (counting ??= count()), }; } From 77f23e5b8380af69a262a35fb90ef8c57fea4520 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 14:53:25 -0400 Subject: [PATCH 4/5] Move the acting-user and batch-commit helpers onto AnonymousAdmission Co-Authored-By: Claude Opus 5.5 --- .../runtime-common/anonymous-admission.ts | 68 +++++++++++ packages/runtime-common/realm.ts | 108 +++++------------- 2 files changed, 94 insertions(+), 82 deletions(-) diff --git a/packages/runtime-common/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts index 47919973d70..69a8894d540 100644 --- a/packages/runtime-common/anonymous-admission.ts +++ b/packages/runtime-common/anonymous-admission.ts @@ -18,8 +18,15 @@ import { recordSafely, type AnonymousRequestEvent, } from './card-operations/telemetry.ts'; +import { + ActingUsers, + type ActingUserResolution, +} from './card-operations/acting-users.ts'; +import type { CommitBatchOptions } from './card-operations/coordinator.ts'; +import type { OperationScope } from './card-operations/dispatch.ts'; import { ANONYMOUS_ELIGIBLE_OPERATIONS, + OperationFailure, isWrite, type OperationError, } from './card-operations/types.ts'; @@ -175,6 +182,11 @@ export interface AnonymousAdmissionEnvironment { 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` + // `config`, and whether that user may write the realm (see + // `ActingUsers`). The gate, explain and capability checks judge a key by + // this same function. + actingUser(key: string): Promise; } export class AnonymousAdmission { @@ -251,6 +263,62 @@ export class AnonymousAdmission { return caller; } + // See `AnonymousAdmissionEnvironment.actingUser`. + resolveActingUser(key: string): Promise { + return this.#env.actingUser(key); + } + + // The acting users of a request a caller who isn't signed in sent, one set + // for the request, which every scope it builds shares. + actingUsersFor(requestContext: RequestContext): ActingUsers | undefined { + if (!requestContext.anonymousCaller) { + return undefined; + } + requestContext.actingUsers ??= new ActingUsers((key) => + this.#env.actingUser(key), + ); + return requestContext.actingUsers; + } + + // What a batch by a caller the realm admitted without a session commits + // with: who it is made as, and its count. `cost` units are counted against + // the caller's budget once every entry is admitted and before anything is + // written, so a batch the gate refuses costs nothing, and one that doesn't + // fit is refused whole. Nothing for any other caller. + commitOptions( + request: Request, + requestContext: RequestContext, + scope: OperationScope, + cost: () => number, + ): Pick { + let caller = requestContext.anonymousCaller; + if (!caller) { + return {}; + } + // Counted once for the request, however many times its batch commits: a + // write the realm commits again to reserialize what it wrote is one write. + // A batch with nothing in it costs nothing. + let counting: Promise | undefined; + let count = async () => { + let units = cost(); + if (units < 1) { + return; + } + let counted = await this.chargeCaller(request, caller, units, { + actingUsers: scope.actingUsers.admitted, + }); + if (counted.kind !== 'counted') { + let refusal = anonymousCountRefusal(counted); + caller.retryAfterSeconds = refusal.meta!.retryAfterSeconds as number; + throw new OperationFailure(refusal); + } + }; + return { + actingUser: () => scope.actingUsers.admitted[0], + beforeCommit: () => (counting ??= count()), + }; + } + // The answer a caller the realm admitted without a session gets in place of // what it asked for, when its admission settled that it is turned away. turnedAway( diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 9f4d9538542..a8dab3fc0c9 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -17,7 +17,6 @@ import { ANONYMOUS_STYLESHEET, ANONYMOUS_WRITE_CHECK, AnonymousAdmission, - anonymousCountRefusal, countRefusalResponse, servedAnonymous, type AnonymousCaller, @@ -394,10 +393,7 @@ import type { BatchEntryResult, CommitBatchOptions, } from './card-operations/coordinator.ts'; -import { - ActingUsers, - type ActingUserResolution, -} from './card-operations/acting-users.ts'; +import type { ActingUsers } from './card-operations/acting-users.ts'; import type { BatchEntry } from './card-operations/executors.ts'; import type { DeferredPrerenderHtml, @@ -2447,7 +2443,7 @@ interface CardWriteAdmission { // Every card+json write commits under it, so for a caller who may not read // the realm, the realm mints the ids of any card the write creates, and a // caller who isn't signed in writes as its grant's acting user and is - // counted once the write is admitted (see `#anonymousCommit`). + // counted once the write is admitted (see `AnonymousAdmission.commitOptions`). batch: { mintIds?: true } & Pick< CommitBatchOptions, 'actingUser' | 'beforeCommit' @@ -2876,6 +2872,25 @@ export class Realm { this.#platformAnonymousRateLimit = opts?.anonymousRateLimit; this.#anonymous = new AnonymousAdmission({ realmURL: this.url, + // Who a key names in the realm's current `realm.json` `config`, and + // whether that user may write the realm, as its ACL says now. + actingUser: async (key) => { + let config = await this.getRealmConfig(); + let user = config?.[key]; + if (typeof user !== 'string' || user === '') { + return { failure: 'key-missing' }; + } + if (!isMatrixUserId(user)) { + return { failure: 'not-a-matrix-id' }; + } + let checker = new RealmPermissionChecker( + await fetchRealmPermissions(this.#dbAdapter, new URL(this.url)), + this.#matrixClient, + ); + return (await checker.can(user, 'write')) + ? { user } + : { failure: 'no-write' }; + }, log: this.#log, limiter: anonymousRateLimiter ?? new DBAnonymousRateLimiter(dbAdapter), hasPolicy: async () => (await this.getRealmPolicy()) !== undefined, @@ -5962,7 +5977,7 @@ export class Realm { caller: scopeCallerFor(caller.actor), coarseDeclined, route: ENVELOPE_ROUTE, - actingUsers: this.#actingUsersFor(requestContext), + actingUsers: this.#anonymous.actingUsersFor(requestContext), }); // An entry may describe the card it runs against with a query instead of // naming one, and this is where such a query becomes cards — against the @@ -6151,7 +6166,7 @@ export class Realm { // entry is admitted: a batch that writes, as its commit begins, and one // that only reads, once its reads have run. A batch that doesn't fit is // refused whole, with nothing written. - let anonymousCommit = this.#anonymousCommit( + let anonymousCommit = this.#anonymous.commitOptions( request, requestContext, scope, @@ -7023,7 +7038,7 @@ export class Realm { document, this.#policyCompileEnvironment(), ), - actingUser: (key) => this.#resolveActingUser(key), + actingUser: (key) => this.#anonymous.resolveActingUser(key), }, targetRealm: (href) => this.#targetRealm(href), // Compiled as this realm's own policy cache compiles the card its @@ -7308,38 +7323,6 @@ export class Realm { : seen; } - // Who `key` names in this realm's current `realm.json` `config`, and - // whether that user may write the realm, as the realm's ACL says now. - async #resolveActingUser(key: string): Promise { - let config = await this.getRealmConfig(); - let user = config?.[key]; - if (typeof user !== 'string' || user === '') { - return { failure: 'key-missing' }; - } - if (!isMatrixUserId(user)) { - return { failure: 'not-a-matrix-id' }; - } - let checker = new RealmPermissionChecker( - await fetchRealmPermissions(this.#dbAdapter, new URL(this.url)), - this.#matrixClient, - ); - return (await checker.can(user, 'write')) - ? { user } - : { failure: 'no-write' }; - } - - // The acting users of a request a caller who isn't signed in sent, one set - // for the request, which every scope it builds shares. - #actingUsersFor(requestContext: RequestContext): ActingUsers | undefined { - if (!requestContext.anonymousCaller) { - return undefined; - } - requestContext.actingUsers ??= new ActingUsers((key) => - this.#resolveActingUser(key), - ); - return requestContext.actingUsers; - } - // Who an operation dispatched from an HTTP request is running for. The actor // is the identity the realm authenticated, which is empty for an anonymous // caller — an operation that reads it has to treat "nobody" as a value @@ -8217,45 +8200,6 @@ export class Realm { return await this.#anonymous.chargeCaller(request, caller, 1); } - // What a batch by a caller the realm admitted without a session commits - // with: who it is made as, and its count. `cost` units are counted against - // the caller's budget once every entry is admitted and before anything is - // written, so a batch the gate refuses costs nothing, and one that doesn't - // fit is refused whole. Nothing for any other caller. - #anonymousCommit( - request: Request, - requestContext: RequestContext, - scope: OperationScope, - cost: () => number, - ): Pick { - let caller = requestContext.anonymousCaller; - if (!caller) { - return {}; - } - // Counted once for the request, however many times its batch commits: a - // write the realm commits again to reserialize what it wrote is one write. - // A batch with nothing in it costs nothing. - let counting: Promise | undefined; - let count = async () => { - let units = cost(); - if (units < 1) { - return; - } - let counted = await this.#anonymous.chargeCaller(request, caller, units, { - actingUsers: scope.actingUsers.admitted, - }); - if (counted.kind !== 'counted') { - let refusal = anonymousCountRefusal(counted); - caller.retryAfterSeconds = refusal.meta!.retryAfterSeconds as number; - throw new OperationFailure(refusal); - } - }; - return { - actingUser: () => scope.actingUsers.admitted[0], - beforeCommit: () => (counting ??= count()), - }; - } - // Whether the realm's policy is the one to judge a caller the ACL refused // with `refusal`: the realm names a policy, and the refusal was of a verified // user's permission rather than of a request that authenticated nobody. @@ -12784,7 +12728,7 @@ export class Realm { caller: scopeCallerFor(this.#callerOf(request, requestContext).actor), coarseDeclined, route: cardJsonRoute(request.method), - actingUsers: this.#actingUsersFor(requestContext), + actingUsers: this.#anonymous.actingUsersFor(requestContext), }); let decision = await resolveFacadeWrite(core, target, base, scope); let assertSideLoads = (included: readonly unknown[] | undefined) => { @@ -12816,7 +12760,7 @@ export class Realm { // refusal here says no more than the gate's own. let batch = { ...this.#mintPosture(coarseDeclined), - ...this.#anonymousCommit(request, requestContext, scope, () => 1), + ...this.#anonymous.commitOptions(request, requestContext, scope, () => 1), }; let assertDestination = (directory: string) => { if (batch.mintIds && directory !== '') { From 0911c6dd2f9e1f1de8361f55bf04ae15ceb44224 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 6 Oct 2026 16:38:12 -0400 Subject: [PATCH 5/5] Keep the write test's rate-limited requests inside one window Co-Authored-By: Claude Opus 5.5 --- .../realm-server/tests/realm-policy-anonymous-write-test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/realm-server/tests/realm-policy-anonymous-write-test.ts b/packages/realm-server/tests/realm-policy-anonymous-write-test.ts index 31a03852d2d..63f73eae8ed 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-write-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-write-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, @@ -402,6 +403,7 @@ module(basename(import.meta.filename), function (hooks) { }), ); await newsroom.indexing(); + await clearOfRateLimitWindowEdge(fields.anonymousRateLimit); } function unauthenticated(response: Response, label: string, assert: Assert) {