diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 1fee5f055d3..2457bbf5c6d 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -21,6 +21,7 @@ "realmServer": [ "catalog-test-subset-test", "realm-policy-activation-test", + "realm-policy-anonymous-read-test", "realm-policy-archived-seal-test", "realm-policy-batch-test", "realm-policy-capabilities-test", diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 0850aba99d0..cc9b5e41c1d 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -1733,6 +1733,7 @@ export async function runTestRealmServerWithRealms({ liveSearchCache, linkShapePolicy, mediaCacheAdapter, + clientAddress, }: { realmsRootPath: string; realms: { @@ -1762,6 +1763,11 @@ export async function runTestRealmServerWithRealms({ // The store every capture surface persists to: the worker's capture task, // each realm's `_screenshot/` route, and the server's `_screenshot-card`. mediaCacheAdapter?: MediaCacheAdapter; + // How the server works out a caller's address. Omit for none in front of + // it, which makes every supertest request the same loopback caller; a test + // that tells callers apart trusts one hop and names each in + // `X-Forwarded-For`. + clientAddress?: ConstructorParameters[0]['clientAddress']; }) { stripTlsEnvVars(); ensureDirSync(realmsRootPath); @@ -1867,6 +1873,7 @@ export async function runTestRealmServerWithRealms({ liveSearchCache, linkShapePolicy, mediaCacheAdapter, + ...(clientAddress ? { clientAddress } : {}), }); let testRealmHttpServer = await awaitListening( testRealmServer.listen(parseInt(serverURL.port)), @@ -3440,6 +3447,27 @@ export const cardInfo = { // `name` under cardInfo.name (matching the CardDef slot); other fields land // on attributes directly. Mirrors the host helper so realm-server tests can // build the same shape without depending on host. +// Waits, where needed, until the current anonymous rate-limit window has +// enough of itself left for a test's requests to land in it together. The +// limiter counts in fixed windows aligned to multiples of `windowSeconds`, so +// two requests either side of a boundary are counted in different windows, +// and a test that expects the second to be refused would see it admitted. +export async function clearOfRateLimitWindowEdge( + limit: unknown, + marginSeconds = 30, +): Promise { + let windowSeconds = (limit as { windowSeconds?: unknown } | undefined) + ?.windowSeconds; + if (typeof windowSeconds !== 'number' || windowSeconds <= 0) { + return; + } + let margin = Math.min(marginSeconds, windowSeconds / 2); + let left = windowSeconds - ((Date.now() / 1000) % windowSeconds); + if (left < margin) { + await new Promise((resolve) => setTimeout(resolve, left * 1000 + 50)); + } +} + export function realmConfigCardJSON( config: { name?: string; diff --git a/packages/realm-server/tests/realm-policy-anonymous-read-test.ts b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts new file mode 100644 index 00000000000..df3eba46bdf --- /dev/null +++ b/packages/realm-server/tests/realm-policy-anonymous-read-test.ts @@ -0,0 +1,876 @@ +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 { + archiveRealm, + parseAddressRanges, + rri, + SupportedMimeType, + unarchiveRealm, +} from '@cardstack/runtime-common'; +import type { + 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 realm nobody may read without signing in, governed by a policy +// card in an Org realm. Its policy opens published articles to callers who +// aren't signed in, and the stored bytes of the published ones too. A Library +// realm's policy opens nothing to such callers. +const NEWSROOM = 'http://127.0.0.1:4444/newsroom/'; +const LIBRARY = 'http://127.0.0.1:4444/library/'; +const ORG = 'http://127.0.0.1:4444/org/'; +const NEWSROOM_POLICY = `${ORG}policies/newsroom`; +const LIBRARY_POLICY = `${ORG}policies/library`; +const EDITOR = '@editor: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 OTHER_VISITOR = '192.0.2.11'; +const BLOCKED_RANGE = '198.51.100.0/24'; +const BLOCKED_VISITOR = '198.51.100.7'; +const OUR_SERVICES = '203.0.113.5'; + +const REALM_POLICY = { + module: rri('@cardstack/catalog/realm-policy/realm-policy'), + name: 'RealmPolicy', +}; + +const ARTICLE_MODULE = ` + import { contains, containsMany, field, CardDef, Component } 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); + static isolated = class Isolated extends Component { + + }; + } +`; + +const ARTICLE = { module: `${NEWSROOM}article`, name: 'Article' }; +const BOOK = { module: `${LIBRARY}article`, name: 'Article' }; + +function article(headline: string, status: string, authorIds: string[] = []) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { headline, status, authorIds }, + meta: { adoptsFrom: { module: '../article', name: 'Article' } }, + }, + }); +} + +function policyCard( + targetType: { module: string; name: string }, + grants: Record[], +) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { rules: [{ targetType, grants }] }, + meta: { adoptsFrom: REALM_POLICY }, + }, + }); +} + +const PUBLISHED = `${NEWSROOM}articles/published`; +const DRAFT = `${NEWSROOM}articles/draft`; +const MISSING = `${NEWSROOM}articles/nowhere`; + +module(basename(import.meta.filename), function (hooks) { + let newsroom: Realm; + let library: Realm; + let org: Realm; + let request: SuperTest; + let server: Server; + let db: PgAdapter; + 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: parseAddressRanges([OUR_SERVICES]).ranges, + }, + realms: [ + { + realmURL: new URL(NEWSROOM), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Newsroom', + policy: NEWSROOM_POLICY, + }), + 'article.gts': ARTICLE_MODULE, + 'articles/published.json': article('Polls open', 'published'), + 'articles/draft.json': article('Polls close', 'draft', [EDITOR]), + }, + // The org admin reads the realms the org's policies govern, which + // is what lets them ask a policy what it decides there. + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [READER]: ['read'], + [ORG_ADMIN]: ['read'], + }, + }, + { + realmURL: new URL(LIBRARY), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Library', + policy: LIBRARY_POLICY, + }), + 'article.gts': ARTICLE_MODULE, + 'articles/published.json': article('Shelved', 'published'), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [ORG_ADMIN]: ['read'], + }, + }, + { + realmURL: new URL(ORG), + fileSystem: { + 'realm.json': realmConfigCardJSON({ name: 'Org' }), + 'policies/newsroom.json': policyCard(ARTICLE, [ + { + operation: 'read', + anonymous: true, + where: '.status == "published"', + }, + // Reads the caller, so it never admits one who isn't signed in, + // even to an article they would otherwise be named on. + { + operation: 'read', + anonymous: true, + where: '.authorIds | any(. == actor())', + }, + { + operation: 'readSource', + anonymous: true, + where: '.status == "published"', + }, + // Opts a write in to such callers, which no route admits them + // to, so it never admits one. + { + operation: 'update', + anonymous: true, + actingUser: 'submitter', + where: '.status == "published"', + }, + ]), + // Grants only signed-in callers. + 'policies/library.json': policyCard(BOOK, [{ operation: 'read' }]), + }, + 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)!; + library = result.realms.find((realm) => realm.url === LIBRARY)!; + org = result.realms.find((realm) => realm.url === ORG)!; + } + + async function stop() { + for (let realm of [newsroom, library, 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) => { + db = dbAdapter; + await start({ dbAdapter, publisher, runner }); + records = []; + setAnonymousRequestSink((record) => records.push(record)); + }, + afterEach: async () => { + setAnonymousRequestSink(undefined); + await stop(); + }, + }); + + // `from` is the caller's address, or null for a request whose address the + // server can't work out. + function readCard( + url: string, + from: string | null = VISITOR, + accept: string = SupportedMimeType.CardJson, + ) { + let req = request.get(new URL(url).pathname).set('Accept', accept); + return from === null ? req : req.set('X-Forwarded-For', from); + } + + // The records of this test's own requests for articles. The realm's own + // renders also reach it without credentials while it indexes, and are + // recorded as any such request is. + function articleRecords() { + return records.filter((record) => record.route.includes('/articles/')); + } + + function readSource(url: string, from: string = VISITOR) { + return readCard(`${url}.json`, from, SupportedMimeType.CardSource); + } + + async function setNewsroomConfig(fields: { + anonymousRateLimit?: unknown; + anonymousBlocklist?: unknown; + }) { + await newsroom.write( + 'realm.json', + realmConfigCardJSON({ + name: 'Newsroom', + policy: NEWSROOM_POLICY, + ...fields, + }), + ); + await newsroom.indexing(); + await clearOfRateLimitWindowEdge(fields.anonymousRateLimit); + } + + function unauthenticated(response: Response, label: string, assert: Assert) { + assert.strictEqual(response.status, 401, `${label}: 401`); + assert.strictEqual( + response.body?.errors?.[0]?.code, + 'actor-required', + `${label}: told to authenticate`, + ); + } + + test('a published article is read by a caller who is not signed in, in a realm nobody may read without signing in', async function (assert) { + let response = await readCard(PUBLISHED); + assert.strictEqual(response.status, 200, response.text); + assert.strictEqual( + response.body.data.attributes.headline, + 'Polls open', + 'the article is served', + ); + let source = await readSource(PUBLISHED); + assert.strictEqual(source.status, 200, 'and its stored bytes'); + assert.true(source.text.includes('Polls open')); + }); + + test('a draft, a missing article, and an article in a realm that opens nothing get the same 401', async function (assert) { + let draft = await readCard(DRAFT); + let missing = await readCard(MISSING); + let library = await readCard(`${LIBRARY}articles/published`); + let draftSource = await readSource(DRAFT); + let missingSource = await readSource(MISSING); + for (let [label, response] of [ + ['a draft', draft], + ['a missing article', missing], + ['a realm that opens nothing', library], + ["a draft's bytes", draftSource], + ["a missing article's bytes", missingSource], + ] as const) { + unauthenticated(response, label, assert); + } + assert.strictEqual(draft.text, missing.text, 'byte-identical bodies'); + assert.strictEqual(draft.text, library.text); + assert.strictEqual(draftSource.text, missingSource.text); + }); + + test("a grant that reads the caller admits nobody who isn't signed in, and is not a fault", async function (assert) { + let before = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + let response = await readCard(DRAFT); + unauthenticated(response, 'a draft its author is named on', assert); + let after = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + assert.strictEqual( + after - before, + 1, + 'only the grant that reads the card was evaluated', + ); + }); + + test('a realm whose policy opens nothing answers as it always has, loading nothing', async function (assert) { + let before = library.__testOnlyPolicyGateStats(); + let response = await readCard(`${LIBRARY}articles/published`); + unauthenticated(response, 'library', assert); + let after = library.__testOnlyPolicyGateStats(); + assert.strictEqual(after.policyLoads, before.policyLoads, 'no policy load'); + assert.strictEqual( + after.predicateEvaluations, + before.predicateEvaluations, + 'no predicate', + ); + assert.deepEqual(articleRecords(), [], 'and records nothing'); + }); + + test('signed-in callers read as they always have', async function (assert) { + let response = await request + .get(new URL(DRAFT).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .set('Authorization', `Bearer ${createJWT(newsroom, READER, ['read'])}`); + assert.strictEqual(response.status, 200, 'a reader reads the draft'); + assert.deepEqual(articleRecords(), [], 'and is no anonymous caller'); + }); + + test('an address that uses up the limit gets 429 until the window turns, and nobody else is affected', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 2, windowSeconds: 600 }, + }); + assert.strictEqual((await readCard(PUBLISHED)).status, 200); + assert.strictEqual((await readSource(PUBLISHED)).status, 200); + let over = await readCard(PUBLISHED); + assert.strictEqual(over.status, 429, 'the third read is refused'); + assert.strictEqual(over.body.errors[0].code, 'rate-limited'); + let retryAfter = Number(over.headers['retry-after']); + assert.true(retryAfter >= 1, `Retry-After says when: ${retryAfter}`); + assert.true(retryAfter <= 600, 'no later than the window turns'); + assert.strictEqual( + over.body.errors[0].meta.retryAfterSeconds, + retryAfter, + 'and the body says the same', + ); + + assert.strictEqual( + (await readCard(PUBLISHED, OTHER_VISITOR)).status, + 200, + 'another address has its own budget', + ); + let reader = await request + .get(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .set('Authorization', `Bearer ${createJWT(newsroom, READER, ['read'])}`); + assert.strictEqual( + reader.status, + 200, + 'a signed-in caller is never limited', + ); + + let limited = articleRecords().filter((r) => r.outcome === 'rate-limited'); + assert.strictEqual(limited.length, 1, 'the refusal is recorded'); + assert.deepEqual( + { + clientIP: limited[0].clientIP, + limit: limited[0].limit, + operation: limited[0].operation, + }, + { + clientIP: VISITOR, + limit: { requests: 2, windowSeconds: 600, from: 'realm' }, + operation: 'read', + }, + ); + }); + + test('a request no grant admits costs the caller nothing', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + for (let i = 0; i < 3; i++) { + unauthenticated(await readCard(DRAFT), `refused probe ${i}`, assert); + } + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'the budget is untouched', + ); + assert.deepEqual( + articleRecords().map((r) => r.outcome), + ['refused', 'refused', 'refused', 'admitted'], + ); + }); + + test('a limit the realm sets nothing for is the platform default', async function (assert) { + await readCard(PUBLISHED); + let [admitted] = articleRecords(); + assert.strictEqual(admitted?.outcome, 'admitted'); + assert.strictEqual(admitted?.limit?.from, 'platform'); + assert.strictEqual(admitted?.count, 1); + }); + + test('a blocked address or range gets the 401 an unadmitted caller gets, whatever the grants say', async function (assert) { + await setNewsroomConfig({ anonymousBlocklist: [BLOCKED_RANGE] }); + let blocked = await readCard(PUBLISHED, BLOCKED_VISITOR); + unauthenticated(blocked, 'a blocked range', assert); + assert.strictEqual( + blocked.text, + (await readCard(MISSING, VISITOR)).text, + 'indistinguishable from a refusal', + ); + assert.strictEqual( + (await readCard(PUBLISHED, VISITOR)).status, + 200, + 'an address outside the range reads', + ); + let block = articleRecords().find((r) => r.outcome === 'blocked'); + assert.strictEqual(block?.blockReason, 'blocklist'); + assert.strictEqual(block?.clientIP, BLOCKED_VISITOR); + }); + + test('a blocklist entry that is not an address closes the realm to callers who are not signed in', async function (assert) { + await setNewsroomConfig({ anonymousBlocklist: ['the spammer'] }); + unauthenticated(await readCard(PUBLISHED), 'any address', assert); + assert.strictEqual( + articleRecords().find((r) => r.outcome === 'blocked')?.blockReason, + 'blocklist-invalid', + ); + }); + + test('a caller whose address cannot be worked out is treated as blocked', async function (assert) { + unauthenticated(await readCard(PUBLISHED, null), 'no address', assert); + let block = articleRecords().find((r) => r.outcome === 'blocked'); + assert.strictEqual(block?.blockReason, 'ip-undetermined'); + assert.strictEqual(block?.clientIP, null); + }); + + test('the address is the one the load balancer appended, not one the caller wrote', async function (assert) { + await setNewsroomConfig({ + anonymousBlocklist: [BLOCKED_RANGE], + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let response = await readCard(PUBLISHED, `${VISITOR}, ${BLOCKED_VISITOR}`); + unauthenticated(response, 'a forged earlier entry', assert); + assert.strictEqual((await readCard(PUBLISHED, VISITOR)).status, 200); + assert.strictEqual( + (await readCard(PUBLISHED, `${BLOCKED_VISITOR}, ${VISITOR}`)).status, + 429, + 'naming another address first does not buy another budget', + ); + }); + + // The org admin asks the policy card what it decides for a caller who isn't + // signed in. + async function explainAnonymous( + policy: string, + target: string, + operation = 'read', + ) { + 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: policy, + data: { actor: '', target, operation }, + }, + ], + }), + ); + assertOk(response); + return response.body['atomic:results'][0]; + } + + function assertOk(response: Response) { + if (response.status !== 200) { + throw new Error(`explain answered ${response.status}: ${response.text}`); + } + } + + test('explain answers for a caller who is not signed in where the policy opens the operation to one', async function (assert) { + let published = await explainAnonymous(NEWSROOM_POLICY, PUBLISHED); + assert.strictEqual(published.actor, null); + assert.strictEqual(published.decision, 'allowed', 'a published article'); + assert.strictEqual(published.reason, 'granted'); + let draft = await explainAnonymous(NEWSROOM_POLICY, DRAFT); + assert.strictEqual(draft.decision, 'denied', 'a draft'); + assert.notStrictEqual( + draft.reason, + 'actor-required', + 'judged against the grants rather than refused for want of an actor', + ); + let library = await explainAnonymous( + LIBRARY_POLICY, + `${LIBRARY}articles/published`, + ); + assert.strictEqual( + library.reason, + 'actor-required', + 'a policy that opens nothing tells such a caller to authenticate', + ); + 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', + ); + }); + + test('a capability check answers a caller who is not signed in, counted once for each pair it asks about', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 4, windowSeconds: 600 }, + }); + let ask = (realm: string, targets: string[]) => + request + .post(`${new URL(realm).pathname}_capabilities`) + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', SupportedMimeType.JSON) + .set('X-Forwarded-For', VISITOR) + .send({ + checks: targets.map((target) => ({ target, operation: 'read' })), + }); + let both = await ask(NEWSROOM, [PUBLISHED, DRAFT]); + assert.strictEqual(both.status, 200, both.text); + assert.true(both.body.checks[0].allowed, 'a published article'); + assert.false(both.body.checks[1].allowed, 'a draft'); + unauthenticated( + await ask(LIBRARY, [`${LIBRARY}articles/published`]), + 'a realm whose policy opens nothing', + assert, + ); + let before = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + let tooMany = await ask(NEWSROOM, [PUBLISHED, PUBLISHED, PUBLISHED]); + assert.strictEqual(tooMany.status, 429, 'three pairs do not fit in two'); + assert.strictEqual(tooMany.body.errors[0].code, 'rate-limited'); + assert.strictEqual( + newsroom.__testOnlyPolicyGateStats().predicateEvaluations, + before, + 'and none of them was checked', + ); + assert.strictEqual((await ask(NEWSROOM, [PUBLISHED])).status, 200); + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'checks and reads draw on one budget', + ); + assert.strictEqual((await readCard(PUBLISHED)).status, 429); + let checks = records.filter((r) => r.route.endsWith('/_capabilities')); + assert.deepEqual( + checks.map((r) => [r.outcome, r.cost]), + [ + ['admitted', 2], + ['rate-limited', 3], + ['admitted', undefined], + ], + 'each check is recorded with what it cost', + ); + }); + + test('our own services are measured and never limited or blocked', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + anonymousBlocklist: [`${OUR_SERVICES}/32`], + }); + for (let i = 0; i < 3; i++) { + assert.strictEqual( + (await readCard(PUBLISHED, OUR_SERVICES)).status, + 200, + `read ${i}`, + ); + } + unauthenticated( + await readCard(DRAFT, OUR_SERVICES), + 'still only what a grant admits', + assert, + ); + assert.deepEqual( + articleRecords().map((r) => r.outcome), + ['infra', 'infra', 'infra', 'refused'], + ); + }); + test('an address over its limit is turned away before anything it asks for runs, whatever it names', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + assert.strictEqual((await readCard(PUBLISHED)).status, 200); + let before = newsroom.__testOnlyPolicyGateStats().predicateEvaluations; + let published = await readCard(PUBLISHED); + let draft = await readCard(DRAFT); + let missing = await readCard(MISSING); + for (let [label, response] of [ + ['a published article', published], + ['a draft', draft], + ['a missing article', missing], + ] as const) { + assert.strictEqual(response.status, 429, `${label}: 429`); + assert.strictEqual(response.body.errors[0].code, 'rate-limited'); + } + // The wait an answer names counts down by the second, so answers a moment + // apart can name different waits. Everything else is the same. + let answer = (response: Response) => + response.text + .replace(/Try again in \d+ seconds/, 'Try again in N seconds') + .replace(/"retryAfterSeconds": \d+/, '"retryAfterSeconds": N'); + assert.strictEqual( + answer(published), + answer(draft), + 'the same answer for each', + ); + assert.strictEqual(answer(published), answer(missing)); + assert.notStrictEqual( + answer(published), + published.text, + 'the wait was taken out of the comparison', + ); + assert.strictEqual( + newsroom.__testOnlyPolicyGateStats().predicateEvaluations, + before, + 'no grant was evaluated for any of them', + ); + }); + + test('a redirect, and a HEAD that finds nothing a grant opens, cost nothing', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let redirect = await readCard(`${PUBLISHED}.json`); + assert.strictEqual(redirect.status, 302, 'the .json spelling redirects'); + for (let [label, url, accept] of [ + ['a draft', DRAFT, SupportedMimeType.CardJson], + ['a missing article', MISSING, SupportedMimeType.CardJson], + ["a draft's bytes", `${DRAFT}.json`, SupportedMimeType.CardSource], + [ + "a missing article's bytes", + `${MISSING}.json`, + SupportedMimeType.CardSource, + ], + ] as const) { + let head = await request + .head(new URL(url).pathname) + .set('Accept', accept) + .set('X-Forwarded-For', VISITOR); + assert.notStrictEqual(head.status, 429, `${label}: not counted`); + assert.notOk(head.headers['etag'], `${label}: describes no card`); + } + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'the budget is untouched', + ); + assert.deepEqual( + articleRecords().map((r) => r.outcome), + ['refused', 'refused', 'refused', 'refused', 'admitted'], + 'the HEADs are refusals and the redirect is nothing', + ); + }); + + test('a HEAD of a card a grant opens answers as its GET does, and is counted', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 2, windowSeconds: 600 }, + }); + let head = await request + .head(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR); + assert.strictEqual(head.status, 200, 'the HEAD is answered'); + assert.true( + String(head.headers['content-type']).startsWith( + SupportedMimeType.CardJson, + ), + 'with the headers of the card it names', + ); + let get = await readCard(PUBLISHED); + assert.strictEqual(get.status, 200); + assert.strictEqual( + head.headers['etag'], + get.headers['etag'], + 'the same validator as the GET', + ); + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 429, + 'the HEAD and the GET each used a unit', + ); + }); + + test('a caller cannot name its own address or claim to be one of our services', async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let forged = () => + readCard(PUBLISHED) + .set('X-Boxel-Client-Class', 'infra') + .set('X-Boxel-Client-IP', OTHER_VISITOR); + assert.strictEqual((await forged()).status, 200); + assert.strictEqual( + (await forged()).status, + 429, + 'still counted, under its own address', + ); + let recorded = articleRecords(); + assert.deepEqual( + recorded.map((r) => [r.outcome, r.clientIP]), + [ + ['admitted', VISITOR], + ['rate-limited', VISITOR], + ], + ); + }); + + test("a stylesheet that goes with a card's markup is served, and counts for nothing", async function (assert) { + await setNewsroomConfig({ + anonymousRateLimit: { requests: 1, windowSeconds: 600 }, + }); + let interned = (await db.execute( + `SELECT hash FROM scoped_css WHERE realm_url = $1 LIMIT 1`, + { bind: [NEWSROOM] }, + )) as { hash: string }[]; + assert.strictEqual( + interned.length, + 1, + 'precondition: indexing interned the article stylesheet', + ); + let path = `${new URL(NEWSROOM).pathname}_scoped-css/article.gts.md5-${interned[0].hash}.glimmer-scoped.css`; + for (let i = 0; i < 3; i++) { + let response = await request + .get(path) + .set('Accept', SupportedMimeType.All) + .set('X-Forwarded-For', VISITOR); + assert.strictEqual(response.status, 200, `stylesheet ${i}`); + } + assert.strictEqual( + (await readCard(PUBLISHED)).status, + 200, + 'the budget is untouched', + ); + }); + + test('an archived realm answers a caller a grant admits with its seal, and anyone else as when it was active', async function (assert) { + let draftActive = await readCard(DRAFT); + await archiveRealm(db, new URL(NEWSROOM)); + try { + let published = await readCard(PUBLISHED); + assert.strictEqual(published.status, 403, 'a published article: 403'); + assert.strictEqual(published.body.errors[0].code, 'archived'); + let draft = await readCard(DRAFT); + unauthenticated(draft, 'a draft', assert); + assert.strictEqual(draft.text, draftActive.text, 'as when it was active'); + unauthenticated(await readCard(MISSING), 'a missing article', assert); + } finally { + await unarchiveRealm(db, new URL(NEWSROOM)); + } + }); + + test('what no anonymous grant can open stays closed to a caller who is not signed in', async function (assert) { + let path = new URL(NEWSROOM).pathname; + let document = article('Breaking', 'published'); + let closed: [string, () => Test][] = [ + [ + 'a card+html read', + () => readCard(PUBLISHED, VISITOR, SupportedMimeType.CardHtml), + ], + [ + 'a search', + () => + request + .post(`${path}_search`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.CardJson) + .set('Content-Type', 'application/json') + .set('X-Forwarded-For', VISITOR) + .send(JSON.stringify({ filter: { type: ARTICLE } })), + ], + [ + 'an operations batch', + () => + 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 }, + ], + }), + ), + ], + [ + 'a card+json create', + () => + request + .post(path) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(document), + ], + [ + 'a card+json update', + () => + request + .patch(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR) + .send(document), + ], + [ + 'a card+json delete', + () => + request + .delete(new URL(PUBLISHED).pathname) + .set('Accept', SupportedMimeType.CardJson) + .set('X-Forwarded-For', VISITOR), + ], + ]; + for (let [label, send] of closed) { + assert.strictEqual((await send()).status, 401, `${label}: 401`); + } + assert.deepEqual(articleRecords(), [], 'and none of them is admitted'); + }); +}); diff --git a/packages/runtime-common/anonymous-admission.ts b/packages/runtime-common/anonymous-admission.ts new file mode 100644 index 00000000000..07328e40ae1 --- /dev/null +++ b/packages/runtime-common/anonymous-admission.ts @@ -0,0 +1,437 @@ +import { + CLIENT_CLASS_HEADER, + CLIENT_IP_HEADER, + INFRA_CLIENT_CLASS, + formatIP, + parseIP, + rangesContain, + rateLimitKey, + type AnonymousAccessSettings, +} from './anonymous-access.ts'; +import type { + AnonymousRateLimiter, + AnonymousRateOutcome, +} from './anonymous-rate-limiter.ts'; +import { errorsDocument } from './card-operations/envelope.ts'; +import { + emitAnonymousRequest, + recordSafely, + type AnonymousRequestEvent, +} from './card-operations/telemetry.ts'; +import 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'; +import type { RequestContext } from './realm.ts'; +import { SupportedMimeType } from './router.ts'; +import type { ResponseWithNodeStream } from './virtual-network.ts'; + +// ============================================================================ +// Callers who aren't signed in. +// +// A realm's policy can admit a request that authenticated nobody, through the +// grants that opt in to such callers (`OperationGrant.anonymous`), and the +// governed realm limits and blocks those callers by address +// (`anonymousRateLimit`, `anonymousBlocklist` in its `realm.json`). This is +// that half of the realm: deciding whether such a request is admitted to what +// its route would run, before anything about its target is resolved, and +// counting what it costs against its address's budget once it has run. +// +// The realm hands a request here only once its ACL refused it for want of +// credentials, on a route tagged with what it would run (`AnonymousDispatch`). +// Everything else about the request is the realm's: the gate decides what a +// grant admits, exactly as it does for a signed-in caller. +// ============================================================================ + +// What a route would run for a caller who isn't signed in: the operations, any +// one of which the realm's policy has to open to such callers for it to admit +// one there (empty means any at all), and whether a successful answer counts +// against the realm's anonymous rate limit. +export interface AnonymousDispatch { + operations: readonly string[]; + charged: boolean; +} + +// A request the realm's policy admitted though it authenticated nobody. +export interface AnonymousCaller { + // What the route was admitted to, or `*` for a route that runs whatever its + // request names. + operation: string; + // The caller's address in canonical form, or null where the realm server + // could not work one out (only a caller of ours can be admitted then). + clientIP: string | null; + // What the caller is counted under (see `rateLimitKey`). + rateLimitKey: string | undefined; + // The caller is one of the platform's own services: counted in telemetry, + // never limited or blocked. + infra: boolean; + charged: boolean; + // 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. + served?: true; + // What the caller is answered with in place of running the request, when + // the realm can't count it: its address has used up the realm's limit, or + // the count couldn't be read. Settled at admission, before anything runs. + turnedAway?: + | { kind: 'rate-limited'; retryAfterSeconds: number } + | { kind: 'unavailable' }; +} + +// The card+json read: what an anonymous grant on `read` opens. +export const ANONYMOUS_CARD_READ: AnonymousDispatch = { + operations: ['read'], + charged: true, +}; +// 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, +}; +// 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, +}; +// 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 +// one. It is counted by the handler, one unit for each pair it asks about, +// before any is checked (see `AnonymousAdmission.count`). +export const ANONYMOUS_CAPABILITY_CHECK: AnonymousDispatch = { + operations: [], + charged: false, +}; + +// How long a caller the realm couldn't count is told to wait. +const COUNT_UNAVAILABLE_RETRY_SECONDS = 5; + +// What a realm supplies to decide about and count its callers who aren't +// signed in, bound to its own `realm.json` and policy. +export interface AnonymousAdmissionEnvironment { + realmURL: string; + log: ReturnType; + limiter: AnonymousRateLimiter; + // Whether the realm names a policy at all. + hasPolicy(): Promise; + // The operations the realm's policy opens to such callers. + openedOperations(): Promise>; + // The realm's limit and blocklist for such callers. + access(): Promise; +} + +export class AnonymousAdmission { + #env: AnonymousAdmissionEnvironment; + + constructor(env: AnonymousAdmissionEnvironment) { + this.#env = env; + } + + // The caller a request that authenticated nobody is, where the realm's + // policy opens one of `dispatch`'s operations to such callers and the realm + // doesn't refuse the caller's address: what it is admitted to, the address + // it is counted under, and whether that address has budget left. Undefined + // for every other request, which is refused as an unauthenticated request + // always has been, before anything about its path is resolved: the question + // reads the compiled policy's summary and the realm's own `realm.json`, and + // loads no target and evaluates no predicate. A realm whose policy opens + // nothing to such callers records nothing. + // + // The address is read only from the headers the realm server sets (see + // `CLIENT_IP_HEADER`). A request without one, which is a request whose + // address the server could not work out, is refused as a blocked one, + // unless it is from the platform's own infrastructure, which is never + // blocked. + async callerFor( + request: Request, + dispatch: AnonymousDispatch, + ): Promise { + if ( + request.headers.has('Authorization') || + !(await this.#env.hasPolicy()) + ) { + return undefined; + } + let opened = await this.#env.openedOperations(); + // A route that runs whatever its request names is admitted wherever the + // policy opens anything to such callers, and recorded as `*`. + let operation = + dispatch.operations.length === 0 + ? opened.size > 0 + ? '*' + : undefined + : dispatch.operations.find((name) => opened.has(name)); + if (operation === undefined) { + return undefined; + } + let ipText = request.headers.get(CLIENT_IP_HEADER); + let address = ipText ? parseIP(ipText) : undefined; + let caller: AnonymousCaller = { + operation, + clientIP: address ? formatIP(address) : null, + rateLimitKey: address ? rateLimitKey(address) : undefined, + infra: request.headers.get(CLIENT_CLASS_HEADER) === INFRA_CLIENT_CLASS, + charged: dispatch.charged, + }; + if (!caller.infra) { + let access = await this.#env.access(); + let blockReason: AnonymousRequestEvent['blockReason'] = + access.invalidBlocklistEntries.length > 0 + ? 'blocklist-invalid' + : !address + ? 'ip-undetermined' + : rangesContain(access.blocklist, address) + ? 'blocklist' + : undefined; + if (blockReason) { + this.#record(request, caller, { outcome: 'blocked', blockReason }); + return undefined; + } + if (caller.charged) { + caller.turnedAway = await this.#checkBudget(request, caller, access); + } + } + return caller; + } + + // 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( + requestContext: RequestContext, + ): ResponseWithNodeStream | undefined { + let turnedAway = requestContext.anonymousCaller?.turnedAway; + if (!turnedAway) { + return undefined; + } + return turnedAway.kind === 'rate-limited' + ? this.#rateLimitedResponse(requestContext, turnedAway.retryAfterSeconds) + : this.#countUnavailableResponse(requestContext); + } + + // Counts a request the realm's policy admitted though it authenticated + // nobody, once it has served what it asked for (see + // `AnonymousCaller.served`), so a request no grant admits, a redirect, and a + // target that isn't there cost the caller nothing. An address with no + // budget left was turned away at admission, before the request ran; one + // that spends its last unit concurrently with another request is answered + // here in the served response's place. A caller of ours is recorded and + // never counted. + async charge( + request: Request, + requestContext: RequestContext, + response: ResponseWithNodeStream, + ): Promise { + let caller = requestContext.anonymousCaller; + if (!caller || caller.turnedAway) { + return response; + } + if (!caller.served) { + if (response.status === 401 || request.method === 'HEAD') { + this.#record(request, caller, { outcome: 'refused' }); + } + return response; + } + if (!caller.charged) { + return response; + } + let answer = await this.count(request, requestContext, 1); + if (answer) { + await discardBody(response); + return answer; + } + return response; + } + + // Counts `cost` invocations by a caller the realm admitted without a + // session against its address's budget, and answers in their place when + // they don't fit or can't be counted. A request whose cost is known before + // it runs, such as a capability check asking about several pairs, is + // counted here first, so one that doesn't fit runs nothing. A caller of + // ours is recorded and never counted. + async count( + request: Request, + requestContext: RequestContext, + cost: number, + ): Promise { + let caller = requestContext.anonymousCaller; + if (!caller || cost < 1) { + return undefined; + } + if (caller.infra) { + this.#record(request, caller, { outcome: 'infra' }); + return undefined; + } + let { limit, limitFrom } = await this.#env.access(); + let outcome: AnonymousRateOutcome; + try { + outcome = await this.#env.limiter.charge({ + realmURL: this.#env.realmURL, + clientIP: caller.rateLimitKey!, + limit, + cost, + }); + } catch (e: unknown) { + this.#recordCountFailure(request, caller, e); + return this.#countUnavailableResponse(requestContext); + } + let recordedLimit = { ...limit, from: limitFrom }; + let costDetail = cost > 1 ? { cost } : {}; + if (outcome.admitted) { + this.#record(request, caller, { + outcome: 'admitted', + limit: recordedLimit, + count: outcome.count, + ...costDetail, + }); + return undefined; + } + this.#record(request, caller, { + outcome: 'rate-limited', + limit: recordedLimit, + retryAfterSeconds: outcome.retryAfterSeconds, + ...costDetail, + }); + return this.#rateLimitedResponse(requestContext, outcome.retryAfterSeconds); + } + + // Whether the caller's address has budget left for one more invocation, + // asked before the invocation runs so that one over the limit costs the + // realm nothing more than this question. The answer is the address's, not + // the target's, so it says nothing about what the request names. A count + // that can't be read turns the caller away rather than letting an + // invocation through uncounted. + async #checkBudget( + request: Request, + caller: AnonymousCaller, + { limit, limitFrom }: AnonymousAccessSettings, + ): Promise { + let outcome: AnonymousRateOutcome; + try { + outcome = await this.#env.limiter.remaining({ + realmURL: this.#env.realmURL, + clientIP: caller.rateLimitKey!, + limit, + }); + } catch (e: unknown) { + this.#recordCountFailure(request, caller, e); + return { kind: 'unavailable' }; + } + if (outcome.admitted) { + return undefined; + } + this.#record(request, caller, { + outcome: 'rate-limited', + limit: { ...limit, from: limitFrom }, + retryAfterSeconds: outcome.retryAfterSeconds, + }); + return { + kind: 'rate-limited', + retryAfterSeconds: outcome.retryAfterSeconds, + }; + } + + #recordCountFailure( + request: Request, + caller: AnonymousCaller, + e: unknown, + ): void { + this.#env.log.warn( + `could not count a request to ${this.#env.realmURL} from a caller who isn't signed in, so it was turned away: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + this.#record(request, caller, { outcome: 'unavailable' }); + } + + // The answer to a request whose caller's address has used up the realm's + // anonymous rate limit: nothing was done, and when to try again. + #rateLimitedResponse( + 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 }, + }); + } + + // 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 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 }, + }); + } + + #record( + request: Request, + caller: AnonymousCaller, + detail: Pick< + AnonymousRequestEvent, + | 'outcome' + | 'blockReason' + | 'limit' + | 'count' + | 'retryAfterSeconds' + | 'cost' + >, + ): void { + recordSafely('anonymous-request', () => + emitAnonymousRequest({ + kind: 'anonymous-request', + realmURL: this.#env.realmURL, + operation: caller.operation, + route: `${request.method} ${new URL(request.url).pathname}`, + clientIP: caller.clientIP, + ...(caller.rateLimitKey ? { rateLimitKey: caller.rateLimitKey } : {}), + correlationId: request.headers.get( + X_BOXEL_LOGGING_CORRELATION_ID_HEADER, + ), + ...detail, + }), + ); + } +} + +// Marks the request as having served what its caller asked for (see +// `AnonymousCaller.served`). +export function servedAnonymous(requestContext: RequestContext): void { + if (requestContext.anonymousCaller) { + requestContext.anonymousCaller.served = true; + } +} + +function countRefusalResponse( + requestContext: RequestContext, + error: OperationError, +): ResponseWithNodeStream { + return createResponse({ + body: JSON.stringify(errorsDocument(error), null, 2), + init: { + status: error.status, + headers: { + 'content-type': SupportedMimeType.JSONAPI, + 'Retry-After': String(error.meta!.retryAfterSeconds), + 'X-Boxel-Realm-Url': requestContext.realm.url, + }, + }, + requestContext, + }); +} + +// 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 { + response.nodeStream?.destroy(); + await response.body?.cancel().catch(() => {}); +} diff --git a/packages/runtime-common/anonymous-rate-limiter.ts b/packages/runtime-common/anonymous-rate-limiter.ts index 7fb008ba484..76a2239deb7 100644 --- a/packages/runtime-common/anonymous-rate-limiter.ts +++ b/packages/runtime-common/anonymous-rate-limiter.ts @@ -28,6 +28,13 @@ export type AnonymousRateOutcome = // in another, and a federated search charges each realm it reaches on its own. export interface AnonymousRateLimiter { charge(charge: AnonymousRateCharge): Promise; + // Whether the caller has already used up the current window, without + // counting anything. Asked before an invocation runs, so a caller over the + // limit is turned away before the realm does the work, and charged only + // once the invocation has served them. + remaining( + probe: Omit, + ): Promise; } // Kept in the database so every realm-server process, and a restarted one, @@ -50,17 +57,45 @@ export class DBAnonymousRateLimiter implements AnonymousRateLimiter { this.#now = opts?.now ?? Date.now; } + #window(limit: AnonymousRateLimit) { + let nowSeconds = Math.floor(this.#now() / 1000); + let windowStart = + Math.floor(nowSeconds / limit.windowSeconds) * limit.windowSeconds; + let expiresAt = windowStart + limit.windowSeconds; + let retryAfterSeconds = Math.max(1, expiresAt - nowSeconds); + return { nowSeconds, windowStart, expiresAt, retryAfterSeconds }; + } + + async remaining({ + realmURL, + clientIP, + limit, + }: Omit): Promise { + let { windowStart, retryAfterSeconds } = this.#window(limit); + let rows = await query(this.#dbAdapter, [ + `SELECT count FROM anonymous_rate_limits WHERE realm_url =`, + param(realmURL), + 'AND client_ip =', + param(clientIP), + 'AND window_start =', + param(windowStart), + 'AND window_seconds =', + param(limit.windowSeconds), + ] as Expression); + let count = rows.length === 0 ? 0 : Number(rows[0].count); + return count < limit.requests + ? { admitted: true, count } + : { admitted: false, retryAfterSeconds }; + } + async charge({ realmURL, clientIP, limit, cost, }: AnonymousRateCharge): Promise { - let nowSeconds = Math.floor(this.#now() / 1000); - let windowStart = - Math.floor(nowSeconds / limit.windowSeconds) * limit.windowSeconds; - let expiresAt = windowStart + limit.windowSeconds; - let retryAfterSeconds = Math.max(1, expiresAt - nowSeconds); + let { nowSeconds, windowStart, expiresAt, retryAfterSeconds } = + this.#window(limit); if (!Number.isInteger(cost) || cost < 1) { throw new Error( diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index 8abadb41503..279155a5046 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -43,6 +43,7 @@ import { isOperationFailure, isWrite, refusalForNonReader, + type BaseOperation, type ExplainedGrantOutcome, type ExplainedIndexLag, type ExplainedRule, @@ -632,6 +633,24 @@ async function listedCards( }; } +// Whether the realm's policy opens `operation` to callers who aren't signed in, +// as the realm reads it to admit one. +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 && + (policy?.anonymous?.operations.includes(operation) ?? false) + ); +} + // What an explain's gate decisions are recorded as having arrived on. const EXPLAIN_ROUTE: PolicyRoute = Object.freeze({ transport: 'explain' as const, @@ -664,8 +683,14 @@ async function explain( ); // A realm that names a policy answers a caller who presented no credentials // with a 401 before the request is routed, for every request its ACL - // declines them, so nothing about the target is read. - if (actor.kind !== 'user' && coarseDeclined !== 'none') { + // declines them that its policy opens nothing to, so nothing about the + // target is read. One its policy opens the operation to is judged by the + // gate as such a caller, against the grants that opt in to them. + if ( + actor.kind !== 'user' && + coarseDeclined !== 'none' && + !(await opensToAnonymous(core, question.operation)) + ) { return refused(base, 'actor-required', { status: 401, code: 'actor-required', diff --git a/packages/runtime-common/card-operations/gate.ts b/packages/runtime-common/card-operations/gate.ts index b48284d630f..b3ddd13e9ec 100644 --- a/packages/runtime-common/card-operations/gate.ts +++ b/packages/runtime-common/card-operations/gate.ts @@ -634,6 +634,12 @@ async function decide( core.policy, notes ? notes.alongside(trace) : trace, ); + // A caller who isn't signed in is admitted only by a grant that opts in to + // them. Every other grant was written for signed-in callers, and admitting + // anyone at all through one would widen it past what its author wrote. + if (scope.caller.kind === 'anonymous') { + matched = matched.filter(({ grant }) => grant.anonymous); + } if (matched.length === 0) { return refuse('no-grant'); } @@ -1393,6 +1399,13 @@ async function firstHolding( if (!where) { return candidate; } + // A predicate that reads the caller never holds for a caller who has no + // actor, so it is not evaluated for one: asked, it would throw for want of + // an actor, and turn a grant that doesn't apply into a fault. + if (actor === undefined && where.readsActor) { + scope.trace?.evaluated(candidate.grant, 'did-not-hold'); + continue; + } stats.predicateEvaluations++; let started = performance.now(); // A predicate judged against the snapshot does not hold for a target with diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index f46340fd1cd..9b496c1a40b 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -258,6 +258,25 @@ export interface RealmPolicyCacheEnvironment extends PolicyCompileEnvironment { // holds now. const NO_OPERATIONS: ReadonlySet = new Set(); +// Whether any grant in a policy card's attributes, as stored, opts in to +// callers who aren't signed in. A card where none does opens nothing to them +// however it compiles, so this answers for it without compiling. One where +// some grant does is compiled to find out what that grant opens. +function opensAnythingToAnonymous(attributes: unknown): boolean { + let rules = (attributes as { rules?: unknown } | undefined)?.rules; + return ( + Array.isArray(rules) && + rules.some( + (rule) => + Array.isArray(rule?.grants) && + rule.grants.some( + (grant: unknown) => + (grant as { anonymous?: unknown } | null)?.anonymous === true, + ), + ) + ); +} + export class RealmPolicyCache { #env: RealmPolicyCacheEnvironment; #current: Compilation | undefined; @@ -281,7 +300,16 @@ export class RealmPolicyCache { #revisit: { card: string; settledAt?: number } | undefined; // How often compiling, revalidating and asking for a card's visit actually // happen, for tests that assert on it rather than on the result alone. - readonly stats = { compiles: 0, revalidations: 0, revisits: 0 }; + readonly stats = { compiles: 0, revalidations: 0, revisits: 0, peeks: 0 }; + // Whether the policy card, as the index last held it, opts any grant in to + // callers who aren't signed in (see `anonymousAdmission`). Kept as long as + // a compiled policy is, and dropped when the card's realm is indexed. + #anonymousPeek: + | { card: string; opensAnything: boolean; readAt: number } + | undefined; + // Bumped whenever an index moves, so a read that began before then doesn't + // keep what it read. + #peekGeneration = 0; constructor(env: RealmPolicyCacheEnvironment) { this.#env = env; @@ -307,15 +335,35 @@ export class RealmPolicyCache { return await this.#refresh(card); } - // The operations the realm's policy opens to callers who aren't signed in, - // or none. Read from the compiled policy and nothing else, so answering such - // a caller loads no target and evaluates no predicate. A policy that can't - // be compiled opens nothing, which leaves that caller with the answer every - // other caller who isn't signed in gets, rather than a failure that would - // tell them the realm names a policy. + // Which operations this realm's policy lets unauthenticated callers run. + // Empty if none. Reading this is cheap: it never loads a card or runs a + // predicate. If the policy fails to compile, the answer is "none", so an + // unauthenticated caller gets the same 401 they'd get from any realm and + // can't tell that this one has a policy at all. + // + // Cost matters here because this runs for every request the ACL rejects + // for missing credentials, and the realm's own renders send such requests + // while it indexes. So before compiling anything, we read the raw policy + // card from the index and look for a grant with `anonymous: true`, and + // remember the answer as long as a compile is kept. If there isn't one, we + // return "none" without compiling. If a fresh compile is already cached, we + // skip that check and use it directly. async anonymousAdmission(): Promise> { let compiled: CompiledRealmPolicy | undefined; try { + let card = await this.#env.policyCard(); + if (!card) { + return NO_OPERATIONS; + } + let current = this.#current; + let warm = + current && + current.compiled.card === card && + !this.#stale && + now() - this.#validatedAt < MAX_UNVALIDATED_MS; + if (!warm && !(await this.#opensAnythingToAnonymous(card))) { + return NO_OPERATIONS; + } compiled = await this.get(); } catch (e: unknown) { log.warn( @@ -330,8 +378,32 @@ export class RealmPolicyCache { : new Set(compiled.anonymous.operations); } + async #opensAnythingToAnonymous(card: string): Promise { + let peek = this.#anonymousPeek; + if ( + peek && + peek.card === card && + now() - peek.readAt < MAX_UNVALIDATED_MS + ) { + return peek.opensAnything; + } + this.stats.peeks++; + let readAt = now(); + let generation = this.#peekGeneration; + let row = await this.#env.readCard(new URL(card)); + let opensAnything = opensAnythingToAnonymous(row?.instance?.attributes); + if (generation === this.#peekGeneration) { + this.#anonymousPeek = { card, opensAnything, readAt }; + } + return opensAnything; + } + // The index of the realm at `realmURL` has moved. indexMoved(realmURL: string): void { + this.#peekGeneration++; + if (this.#anonymousPeek?.card.startsWith(realmURL)) { + this.#anonymousPeek = undefined; + } for (let refresh of this.#inFlight) { refresh.moved.push(realmURL); } @@ -354,6 +426,8 @@ export class RealmPolicyCache { // Drops the cached compilation and zeroes the counts, so a test starts from // a cold cache. clear(): void { + this.#anonymousPeek = undefined; + this.#peekGeneration++; this.#current = undefined; this.#stale = false; this.#joinable = undefined; @@ -361,6 +435,7 @@ export class RealmPolicyCache { this.stats.compiles = 0; this.stats.revalidations = 0; this.stats.revisits = 0; + this.stats.peeks = 0; } #refresh(card: string): Promise { diff --git a/packages/runtime-common/card-operations/telemetry.ts b/packages/runtime-common/card-operations/telemetry.ts index f422daf54c4..d631482d6ca 100644 --- a/packages/runtime-common/card-operations/telemetry.ts +++ b/packages/runtime-common/card-operations/telemetry.ts @@ -501,6 +501,83 @@ export function emitPolicyCompile(event: PolicyCompileEvent): void { ); } +// ============================================================================ +// Per-request telemetry for callers a realm's policy admits without a session. +// +// One line for each such request the realm judges against its anonymous +// controls: one its policy opens the operation to, from a caller who isn't +// signed in. A realm whose policy opens nothing to such callers writes none, +// and answers them as a realm with no anonymous grants always has. The client +// address is recorded as the realm server worked it out, so an operator can +// find the addresses to block and follow one visitor's requests through +// `realm:requests` by the correlation id. +// ============================================================================ + +export interface AnonymousRequestEvent { + kind: 'anonymous-request'; + realmURL: string; + // The operation the route was admitted to, or `*` for a route that runs + // whatever its request names: the operations envelope and the capability + // check. + operation: string; + route: string; + // - `admitted`: a grant admitted it and it was counted against the limit. + // - `infra`: a grant admitted it, from one of the platform's own egress + // addresses, which is counted here but never limited or blocked. + // - `refused`: no grant admitted it, and it was answered as an + // unauthenticated request is. + // - `blocked`: the realm refused the caller's address before any grant was + // asked (see `blockReason`). + // - `rate-limited`: the address had used up its limit, so it was turned + // away before anything ran, whatever it asked for. + // - `unavailable`: the realm couldn't read or update the address's count, + // so it was turned away rather than let through uncounted. + outcome: + | 'admitted' + | 'infra' + | 'refused' + | 'blocked' + | 'rate-limited' + | 'unavailable'; + blockReason?: 'blocklist' | 'ip-undetermined' | 'blocklist-invalid'; + clientIP: string | null; + // What the caller is counted under: the address, or the /64 an IPv6 + // address is in. + rateLimitKey?: string; + limit?: { + requests: number; + windowSeconds: number; + from: 'realm' | 'platform'; + }; + // The window's count once this request was counted. + count?: number; + // 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; + retryAfterSeconds?: number; + correlationId: string | null; +} + +let anonymousRequestSink: ((event: AnonymousRequestEvent) => void) | undefined; + +export function setAnonymousRequestSink( + sink: ((event: AnonymousRequestEvent) => void) | undefined, +): void { + anonymousRequestSink = sink; +} + +let anonymousRequestLog: ReturnType | undefined; + +export function emitAnonymousRequest(event: AnonymousRequestEvent): void { + if (anonymousRequestSink) { + anonymousRequestSink(event); + return; + } + (anonymousRequestLog ??= logger(OPERATIONS_CHANNEL)).info( + JSON.stringify({ channel: OPERATIONS_CHANNEL, ...event }), + ); +} + // Build and write one record, logging and swallowing anything that throws on // the way. A record describes a decision and never changes one: a sink or a // log transport that throws must not turn an admitted invocation into a 500, diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index b3ff1deb2da..9f605193474 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -1436,6 +1436,15 @@ export type OperationErrorCode = // of them could land. Serial order is how a batch says that two entries // touch the same target. | 'conflicting-targets' + // A caller who isn't signed in, admitted by the realm's policy, has used up + // the invocations the realm allows their address in the current window. + // Nothing was done. Carries a 429 with `Retry-After`, and + // `meta.retryAfterSeconds`. + | 'rate-limited' + // The realm couldn't count an invocation by a caller who isn't signed in, so + // it turned the caller away rather than let it through uncounted. Nothing + // was done. Carries a 503 with `Retry-After`. + | 'rate-limit-unavailable' // The operation could not be carried out for a reason that is not the // caller's — an unreadable definition, an errored index row, a failure // inside the executor. diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 463b05f0a5b..2e58e4f08cb 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -5,6 +5,20 @@ import { type AnonymousAccessSettings, type AnonymousRateLimit, } from './anonymous-access.ts'; +import { + ANONYMOUS_BYTES_READ, + ANONYMOUS_CAPABILITY_CHECK, + ANONYMOUS_CARD_READ, + ANONYMOUS_STYLESHEET, + AnonymousAdmission, + servedAnonymous, + type AnonymousCaller, + type AnonymousDispatch, +} from './anonymous-admission.ts'; +import { + DBAnonymousRateLimiter, + type AnonymousRateLimiter, +} from './anonymous-rate-limiter.ts'; import { resolveRangeHeader } from './http-range.ts'; import { awaitRealmIndexSettled, @@ -2291,6 +2305,12 @@ export type RequestContext = { // all of which take the conservative bounded hold. Identity, not // authority, like `authenticatedUser`. anonymous?: true; + // Set when the realm's policy admitted a request that authenticated nobody + // (see `#admitsAnonymous`): the operation it was admitted to, and the + // caller's address as the realm server worked it out. Such a caller is + // 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; // 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 @@ -2353,6 +2373,13 @@ interface RequestDispatch { // passes the seal as an operational endpoint, but meets the realm ACL like // any other read. answersWithoutCredentials?: boolean; + // What the dispatch would run for a caller who isn't signed in, where a + // grant could admit one: the operations, any one of which the realm's + // policy has to open to such callers for it to admit them here (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. + anonymous?: AnonymousDispatch; handle: () => Promise; } @@ -2582,6 +2609,9 @@ export class Realm { // `#cachedRealmConfig`, like the policy pointer. #cachedAnonymousAccess: AnonymousAccessSettings | undefined; #platformAnonymousRateLimit: AnonymousRateLimit | undefined; + // Decides about, and counts, the callers who aren't signed in that this + // realm's policy admits. + #anonymous: AnonymousAdmission; // Bumped by every invalidation, and captured by a parse before it starts. // A parse that reads the realm's state and then has an index swap land // underneath it is holding values the realm has already moved past, so it @@ -2730,6 +2760,7 @@ export class Realm { mediaCacheAdapter, cardDocumentCache, realmFor, + anonymousRateLimiter, }: { url: string; adapter: RealmAdapter; @@ -2765,6 +2796,10 @@ export class Realm { // that realm only for a caller who may read it. Without it, an explain // reaches only this realm's own targets. realmFor?: (url: URL) => Promise; + // Where the realm counts the invocations of callers its policy admits + // without a session. Unset, the realm's database, which every process + // serving the realm shares. + anonymousRateLimiter?: AnonymousRateLimiter; }, opts?: Options, ) { @@ -2804,6 +2839,14 @@ export class Realm { this.#readIndexDrainBudgetMs = opts?.readIndexDrainBudgetMs ?? READ_INDEX_DRAIN_BUDGET_MS; this.#platformAnonymousRateLimit = opts?.anonymousRateLimit; + this.#anonymous = new AnonymousAdmission({ + realmURL: this.url, + log: this.#log, + limiter: anonymousRateLimiter ?? new DBAnonymousRateLimiter(dbAdapter), + hasPolicy: async () => (await this.getRealmPolicy()) !== undefined, + openedOperations: () => this.getAnonymousAdmission(), + access: () => this.getAnonymousAccess(), + }); let owner: string | undefined; let _fetch = fetcher( virtualNetwork.fetch, @@ -3012,7 +3055,7 @@ export class Realm { '/_capabilities', SupportedMimeType.JSON, this.handleCapabilities.bind(this), - APPLIES_ARCHIVED_SEAL, + { ...APPLIES_ARCHIVED_SEAL, anonymous: ANONYMOUS_CAPABILITY_CHECK }, ) .post( '/_cancel-indexing-job', @@ -3044,12 +3087,10 @@ export class Realm { this.createCard.bind(this), APPLIES_ARCHIVED_SEAL, ) - .get( - '/.*', - SupportedMimeType.CardJson, - this.getCard.bind(this), - APPLIES_ARCHIVED_SEAL, - ) + .get('/.*', SupportedMimeType.CardJson, this.getCard.bind(this), { + ...APPLIES_ARCHIVED_SEAL, + anonymous: ANONYMOUS_CARD_READ, + }) // The card+html, file-meta+html and markdown reads serve what the index // holds for a path without running an operation, so they are answered // on the realm ACL alone, as the file-meta read below is: a caller a @@ -6508,6 +6549,17 @@ export class Realm { }); } let checks = parseCapabilityChecks(body); + // A caller admitted without a session is counted for every pair before + // any is checked: each one loads its target and runs its grants as a + // read would. + let turnedAway = await this.#anonymous.count( + request, + requestContext, + checks.length, + ); + if (turnedAway) { + return turnedAway; + } let { actor } = this.#callerOf(request, requestContext); let lanes = await this.#capabilityLanes(request, requestContext); let { coarseDeclined } = lanes; @@ -7592,7 +7644,11 @@ export class Realm { if ( !dispatch.consumesCoarseOutcome || requiredPermission === 'realm-owner' || - !(await this.#admitsDespiteCoarseRefusal(request, requestContext)) + !(await this.#admitsDespiteCoarseRefusal( + request, + requestContext, + dispatch.anonymous, + )) ) { let answer = await this.#refusalUnderPolicy( request, @@ -7640,7 +7696,17 @@ export class Realm { message: 'search index is not available', }); } - return await dispatch.handle(); + // A caller admitted without a session whose address has no budget left + // is answered before the request runs (see `AnonymousAdmission.callerFor`). + let turnedAway = this.#anonymous.turnedAway(requestContext); + if (turnedAway) { + return turnedAway; + } + return await this.#anonymous.charge( + request, + requestContext, + await dispatch.handle(), + ); } catch (e) { if (e instanceof AuthenticationError) { return createResponse({ @@ -7754,6 +7820,7 @@ export class Realm { ) { return { ...APPLIES_ARCHIVED_SEAL, + anonymous: ANONYMOUS_STYLESHEET, handle: () => this.serveHashedScopedCSS(request, requestContext), }; } @@ -7798,6 +7865,7 @@ export class Realm { appliesArchivedSeal: matched.appliesArchivedSeal, operationalEndpoint: matched.operationalEndpoint, answersWithoutCredentials: matched.operationalEndpoint, + ...(matched.anonymous ? { anonymous: matched.anonymous } : {}), handle: serve, }; } @@ -7864,6 +7932,7 @@ export class Realm { ): RequestDispatch { return { consumesCoarseOutcome: true, + anonymous: ANONYMOUS_BYTES_READ, handle: async () => { if (request.method === 'HEAD' && requestContext.coarseAllowed) { let probe = await this.#readProbe(request, requestContext); @@ -7875,10 +7944,18 @@ export class Realm { requestContext.coarseAllowed = false; requestContext.coarseRefusal = refusal; if ( - !(await this.#admitsDespiteCoarseRefusal(request, requestContext)) + !(await this.#admitsDespiteCoarseRefusal( + request, + requestContext, + ANONYMOUS_BYTES_READ, + )) ) { return this.realmIdentityResponse(requestContext); } + let turnedAway = this.#anonymous.turnedAway(requestContext); + if (turnedAway) { + return turnedAway; + } } } if ( @@ -7955,20 +8032,50 @@ export class Realm { // // - The realm names a policy. A realm with none answers every refusal // exactly as the ACL gave it. - // - The caller is someone. A policy grants by who is asking, and a request - // that authenticated nobody is told to authenticate, whatever its path - // names and whatever the policy holds. + // - The caller is someone, or the route runs an operation the policy opens + // to callers who aren't signed in and the realm doesn't refuse the + // caller's address (see `#admitsAnonymous`). Any other request that + // authenticated nobody is told to authenticate, whatever its path names + // and whatever the policy holds. async #admitsDespiteCoarseRefusal( request: Request, requestContext: RequestContext, + anonymous?: AnonymousDispatch, ): Promise { if (this.#testOnlyCoarseAdmission) { return await this.#testOnlyCoarseAdmission(request, requestContext); } - return await this.#policyJudges( - requestContext.coarseRefusal, - requestContext, - ); + if ( + await this.#policyJudges(requestContext.coarseRefusal, requestContext) + ) { + return true; + } + return anonymous + ? await this.#admitsAnonymous(request, requestContext, anonymous) + : false; + } + + // Whether the realm's policy admits a request that authenticated nobody, and + // that its ACL refused for want of credentials, to what its route would run + // (see `AnonymousAdmission.callerFor`). + async #admitsAnonymous( + request: Request, + requestContext: RequestContext, + dispatch: AnonymousDispatch, + refusal: unknown = requestContext.coarseRefusal, + ): Promise { + if ( + !(refusal instanceof CoarseAuthenticationRequired) || + requestContext.authenticatedUser + ) { + return false; + } + let caller = await this.#anonymous.callerFor(request, dispatch); + if (!caller) { + return false; + } + requestContext.anonymousCaller = caller; + return true; } // Whether the realm's policy is the one to judge a caller the ACL refused @@ -8025,6 +8132,16 @@ export class Realm { if (!unauthenticated) { return notFound(request, requestContext); } + return this.#authenticationRequired(requestContext); + } + + // What a realm with a policy tells a request that authenticated nobody and + // that nothing admitted: to authenticate. A caller the policy admitted + // without a session is told the same wherever no grant admits what it asked + // for, including a target that isn't there, so the answer is the same for a + // path that names a card and one that names nothing, and the same as for a + // caller no grant admitted at all. + #authenticationRequired(requestContext: RequestContext): Response { return createResponse({ body: JSON.stringify( errorsDocument({ @@ -8076,8 +8193,11 @@ export class Realm { // this once, ahead of the checks that could each find nothing, rather than // at whichever check did. Built at each, the stack would name the check. #bytesNotThere(request: Request, requestContext: RequestContext): Response { - return request.method === 'HEAD' - ? this.realmIdentityResponse(requestContext) + if (request.method === 'HEAD') { + return this.realmIdentityResponse(requestContext); + } + return requestContext.anonymousCaller + ? this.#authenticationRequired(requestContext) : notFound(request, requestContext); } @@ -10107,6 +10227,7 @@ export class Realm { createdAt?: number | null; }, ): Promise { + servedAnonymous(requestContext); let contentType = options?.defaultHeaders?.['content-type']; // Only advertise `public` caching when the realm is world-readable; // otherwise the response is auth-gated and must not be stored by shared @@ -12995,13 +13116,26 @@ export class Realm { // A `HEAD` passes the realm's permission check whoever sends it, so it // asks the read question itself. A caller the ACL would not let read is // answered by the realm's policy, as their `GET` is, where the policy has - // them to judge. Everyone else it refuses gets the discovery answer. + // them to judge, including a caller who isn't signed in where the policy + // opens `read` to one. Everyone else it refuses gets the discovery answer. let probe = await this.#readProbe(request, requestContext); let coarseDeclined: { coarseDeclined?: true } = {}; if (!probe.allowed) { - if (!(await this.#policyJudges(probe.refusal, requestContext))) { + if ( + !(await this.#policyJudges(probe.refusal, requestContext)) && + !(await this.#admitsAnonymous( + request, + requestContext, + ANONYMOUS_CARD_READ, + probe.refusal, + )) + ) { return this.realmIdentityResponse(requestContext); } + let turnedAway = this.#anonymous.turnedAway(requestContext); + if (turnedAway) { + return turnedAway; + } coarseDeclined = { coarseDeclined: true }; } // Read-your-writes, as on the `GET`: a validator computed off an index the @@ -13077,6 +13211,7 @@ export class Realm { `the headers-only read of ${url.href} answered with something other than headers`, ); } + servedAnonymous(requestContext); if (result.type === 'file-meta') { // A `GET` of a path that holds bytes answers with the file's metadata // document, which is derived from those bytes and has no index row @@ -13411,6 +13546,9 @@ export class Realm { let cacheOutcomeHeader: Record = cacheOutcome ? { [CARD_DOCUMENT_CACHE_HEADER]: cacheOutcome } : {}; + if (assembly.kind === 'not-found' && requestContext.anonymousCaller) { + return this.#authenticationRequired(requestContext); + } if (assembly.kind !== 'document') { let response = await this.#respondToCardJsonOutcome( assembly, @@ -13423,6 +13561,7 @@ export class Realm { } return response; } + servedAnonymous(requestContext); return createResponse({ body: assembly.body, varyOn: LINK_SHAPE_VARY, @@ -13602,6 +13741,7 @@ export class Realm { // document — valid JSON the caller discriminates via // `data.type === 'file-meta'`, instead of raw bytes that crash a // downstream `response.json()`. + servedAnonymous(requestContext); return createResponse({ body: assembly.body, init: { headers: { 'content-type': SupportedMimeType.CardJson } }, diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index 0b419a6755e..f876c44b7e6 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -308,6 +308,18 @@ export interface RouteOptions { // anyway. It records that the route was weighed and left to the ACL, so // that a route that says nothing about the policy is one nobody decided. 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 + // consumes the ACL's outcome can take this. A route without it answers such + // a caller as the ACL does. + anonymous?: AnonymousRouteOptions; +} + +export interface AnonymousRouteOptions { + operations: readonly string[]; + charged: boolean; } export interface Route { @@ -318,6 +330,7 @@ export interface Route { grantableBytes: boolean; operationalEndpoint: boolean; aclOnly: boolean; + anonymous?: AnonymousRouteOptions; } export interface RouteDescription { @@ -423,6 +436,7 @@ export class Router { grantableBytes: opts.grantableBytes === true, operationalEndpoint: opts.operationalEndpoint === true, aclOnly: opts.aclOnly === true, + ...(opts.anonymous ? { anonymous: opts.anonymous } : {}), }); }