diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 2cb23f652d8..eb616d6d916 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 df3eba46bdf..499d06c82e7 100644 --- a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -193,8 +193,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, @@ -554,9 +554,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', ); }); @@ -825,18 +825,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, + }, ], }), ), @@ -871,6 +874,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/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..63f73eae8ed --- /dev/null +++ b/packages/realm-server/tests/realm-policy-anonymous-write-test.ts @@ -0,0 +1,761 @@ +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 { + clearOfRateLimitWindowEdge, + 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/'; +// 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'; +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 SPAM = `${NEWSROOM}articles/spam`; +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: '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. + 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 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(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'), + 'articles/spam.json': article('Buy now', 'spam'), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + [READER]: ['read'], + [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'] }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + 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, 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 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 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) + .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(); + await clearOfRateLimitWindowEdge(fields.anonymousRateLimit); + } + + 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'); + }); + + 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/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts index 621457af3dd..69a8894d540 100644 --- a/packages/runtime-common/anonymous-admission.ts +++ b/packages/runtime-common/anonymous-admission.ts @@ -18,7 +18,18 @@ import { recordSafely, type AnonymousRequestEvent, } from './card-operations/telemetry.ts'; -import type { OperationError } from './card-operations/types.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'; import { createResponse } from './create-response.ts'; import type { logger } from './log.ts'; import { X_BOXEL_LOGGING_CORRELATION_ID_HEADER } from './prerender-headers.ts'; @@ -45,11 +56,19 @@ 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 whether a successful answer counts -// against the realm's anonymous rate limit. +// one there (empty means any at all), and how it counts against the realm's +// anonymous rate limit. export 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. @@ -65,7 +84,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. @@ -87,25 +109,25 @@ export type AnonymousCount = // The card+json read: what an anonymous grant on `read` opens. export 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. export 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. export 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. export 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 @@ -113,7 +135,36 @@ export const ANONYMOUS_STYLESHEET: AnonymousDispatch = { // before any is checked (see `AnonymousAdmission.count`). export 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. +export const ANONYMOUS_CARD_CREATE: AnonymousDispatch = { + operations: ['create'], + counted: 'by-handler', +}; +export const ANONYMOUS_CARD_UPDATE: AnonymousDispatch = { + operations: ['update'], + counted: 'by-handler', +}; +export 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. +export 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 +// admitted, and one that only reads, one unit per entry once its reads have +// run. +export const ANONYMOUS_OPERATIONS: AnonymousDispatch = { + operations: [], + counted: 'by-handler', }; // How long a caller the realm couldn't count is told to wait. @@ -131,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 { @@ -184,7 +240,7 @@ export class AnonymousAdmission { clientIP: address ? formatIP(address) : null, rateLimitKey: address ? rateLimitKey(address) : undefined, infra: request.headers.get(CLIENT_CLASS_HEADER) === INFRA_CLIENT_CLASS, - charged: dispatch.charged, + counted: dispatch.counted, }; if (!caller.infra) { let access = await this.#env.access(); @@ -200,13 +256,69 @@ export class AnonymousAdmission { this.#record(request, caller, { outcome: 'blocked', blockReason }); return undefined; } - if (caller.charged) { + if (caller.counted !== 'never') { caller.turnedAway = await this.#checkBudget(request, caller, access); } } 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( @@ -238,13 +350,26 @@ export class AnonymousAdmission { 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.#record(request, caller, { outcome: 'refused' }); + let failures = requestContext.actingUsers?.failures ?? []; + this.#record(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.count(request, requestContext, 1); @@ -291,9 +416,10 @@ export class AnonymousAdmission { request: Request, caller: AnonymousCaller, cost: number, + detail: Pick = {}, ): Promise { if (caller.infra) { - this.#record(request, caller, { outcome: 'infra' }); + this.#record(request, caller, { outcome: 'infra', ...detail }); return { kind: 'counted' }; } let { limit, limitFrom } = await this.#env.access(); @@ -317,6 +443,7 @@ export class AnonymousAdmission { limit: recordedLimit, count: outcome.count, ...costDetail, + ...detail, }); return { kind: 'counted' }; } @@ -387,13 +514,10 @@ export class AnonymousAdmission { requestContext: RequestContext, retryAfterSeconds: number, ): ResponseWithNodeStream { - return countRefusalResponse(requestContext, { - 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 countRefusalResponse( + requestContext, + anonymousCountRefusal({ kind: 'rate-limited', retryAfterSeconds }), + ); } // The answer to a request from a caller who isn't signed in that the realm @@ -401,13 +525,10 @@ export class AnonymousAdmission { #countUnavailableResponse( requestContext: RequestContext, ): ResponseWithNodeStream { - return countRefusalResponse(requestContext, { - 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 }, - }); + return countRefusalResponse( + requestContext, + anonymousCountRefusal({ kind: 'unavailable' }), + ); } #record( @@ -421,6 +542,8 @@ export class AnonymousAdmission { | 'count' | 'retryAfterSeconds' | 'cost' + | 'actingUsers' + | 'actingUserFailures' >, ): void { recordSafely('anonymous-request', () => @@ -448,7 +571,34 @@ export function servedAnonymous(requestContext: RequestContext): void { } } -function countRefusalResponse( +// 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. +export 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 }, + }; +} + +// The answer to a request whose caller wasn't counted (see +// `anonymousCountRefusal`). +export function countRefusalResponse( requestContext: RequestContext, error: OperationError, ): ResponseWithNodeStream { 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 1223eee6f99..5720895ac48 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'; @@ -377,6 +378,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. @@ -415,6 +420,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 @@ -438,6 +447,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) { @@ -466,6 +480,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 b3ddd13e9ec..71ce03f2493 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 @@ -639,6 +651,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'); @@ -688,6 +706,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)) { @@ -1223,6 +1242,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 d631482d6ca..4e5c377fec6 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'; @@ -554,6 +555,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 dd7f2db6ced..a8dab3fc0c9 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -9,9 +9,15 @@ import { ANONYMOUS_BYTES_READ, ANONYMOUS_CAPABILITY_CHECK, ANONYMOUS_CARD_READ, + ANONYMOUS_CARD_CREATE, + ANONYMOUS_CARD_DELETE, + ANONYMOUS_CARD_UPDATE, + ANONYMOUS_OPERATIONS, ANONYMOUS_SEARCH, ANONYMOUS_STYLESHEET, + ANONYMOUS_WRITE_CHECK, AnonymousAdmission, + countRefusalResponse, servedAnonymous, type AnonymousCaller, type AnonymousCount, @@ -385,7 +391,9 @@ import { import type { BatchCore, BatchEntryResult, + CommitBatchOptions, } from './card-operations/coordinator.ts'; +import type { ActingUsers } from './card-operations/acting-users.ts'; import type { BatchEntry } from './card-operations/executors.ts'; import type { DeferredPrerenderHtml, @@ -475,6 +483,7 @@ import { type MatrixClient, ensureFullMatrixUserId, getMatrixUsername, + isMatrixUserId, } from './matrix-client.ts'; import { PACKAGES_FAKE_ORIGIN } from './package-shim-handler.ts'; @@ -2104,9 +2113,12 @@ 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`), 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 @@ -2298,9 +2310,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`, @@ -2313,6 +2326,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 @@ -2378,13 +2394,21 @@ 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; } +// 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, +}; + // A route as `Realm.routeDescriptions` lists it: a router route, the hashed // stylesheet serve under its path prefix, or the fallback file and module // serve for one method, which answers whatever path and media type no router @@ -2417,8 +2441,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 `AnonymousAdmission.commitOptions`). + 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; @@ -2843,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, @@ -3034,25 +3082,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 @@ -3093,7 +3141,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, @@ -3126,13 +3174,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.#anonymous.commitOptions( + 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`). @@ -6158,6 +6220,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, @@ -6649,7 +6712,19 @@ export class Realm { return { coarseDeclined: readAllowed ? 'none' : 'all' }; } let coarseDeclined: CoarseDeclined = readAllowed ? 'writes' : 'all'; - if (await this.#policyJudges(write.refusal, requestContext)) { + // 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 || + (write.refusal instanceof CoarseAuthenticationRequired && + !requestContext.authenticatedUser && + (await this.#anonymous.callerFor(request, ANONYMOUS_WRITE_CHECK))) + ) { return { coarseDeclined }; } return { @@ -6963,6 +7038,7 @@ export class Realm { document, this.#policyCompileEnvironment(), ), + actingUser: (key) => this.#anonymous.resolveActingUser(key), }, targetRealm: (href) => this.#targetRealm(href), // Compiled as this realm's own policy cache compiles the card its @@ -7235,9 +7311,16 @@ 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 an operation dispatched from an HTTP request is running for. The actor @@ -8182,16 +8265,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: { @@ -12654,6 +12728,7 @@ export class Realm { caller: scopeCallerFor(this.#callerOf(request, requestContext).actor), coarseDeclined, route: cardJsonRoute(request.method), + actingUsers: this.#anonymous.actingUsersFor(requestContext), }); let decision = await resolveFacadeWrite(core, target, base, scope); let assertSideLoads = (included: readonly unknown[] | undefined) => { @@ -12683,7 +12758,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.#anonymous.commitOptions(request, requestContext, scope, () => 1), + }; let assertDestination = (directory: string) => { if (batch.mintIds && directory !== '') { throw new OperationFailure({ @@ -12870,6 +12948,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 countRefusalResponse(requestContext, error); + } if (status === 400) { return badRequest({ message: detail, requestContext, ...identity }); } 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 {