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